diff --git a/cmd/cc-backend/main.go b/cmd/cc-backend/main.go index 1a8aa4c..505cb8b 100644 --- a/cmd/cc-backend/main.go +++ b/cmd/cc-backend/main.go @@ -72,7 +72,7 @@ func main() { flag.BoolVar(&flagMigrateDB, "migrate-db", false, "Migrate database to supported version and exit") flag.BoolVar(&flagLogDateTime, "logdate", false, "Set this flag to add date and time to log messages") flag.StringVar(&flagConfigFile, "config", "./config.json", "Specify alternative path to `config.json`") - flag.StringVar(&flagNewUser, "add-user", "", "Add a new user. Argument format: `:[admin,support,api,user]:`") + flag.StringVar(&flagNewUser, "add-user", "", "Add a new user. Argument format: `:[admin,support,manager,api,user]:`") flag.StringVar(&flagDelUser, "del-user", "", "Remove user by `username`") flag.StringVar(&flagGenJWT, "jwt", "", "Generate and print a JWT for the user specified by its `username`") flag.StringVar(&flagImportJob, "import-job", "", "Import a job. Argument format: `:,...`") @@ -142,7 +142,7 @@ func main() { } if err := authentication.AddUser(&auth.User{ - Username: parts[0], Password: parts[2], Roles: strings.Split(parts[1], ","), + Username: parts[0], Projects: make([]string, 0), Password: parts[2], Roles: strings.Split(parts[1], ","), }); err != nil { log.Fatalf("adding '%s' user authentication failed: %v", parts[0], err) } diff --git a/docs/searchbar.md b/docs/searchbar.md index c80620c..3a6eb2b 100644 --- a/docs/searchbar.md +++ b/docs/searchbar.md @@ -1,42 +1,35 @@ -## Docs for ClusterCockpit Searchbar +# Docs for ClusterCockpit Searchbar -### Usage +## Usage * Searchtags are implemented as `type:` search-string - * Types `jobId, jobName, projectId, username, name` for roles `admin` and `support` - * `jobName` is jobName as persisted in `job.meta_data` table-column - * `username` is actual account identifier as persisted in `job.user` table-column - * `name` is account owners name as persisted in `user.name` table-column - * Types `jobId, jobName` for role `user` - * Examples: - * `jobName:myJob12` - * `jobId:123456` - * `username:abcd100` - * `name:Paul` + * Types `jobId, jobName, projectId, username, name` for roles `admin` and `support` + * `jobName` is jobName as persisted in `job.meta_data` table-column + * `username` is actual account identifier as persisted in `job.user` table-column + * `name` is account owners name as persisted in `user.name` table-column + * Types `jobId, jobName, projectId` for role `user` + * Examples: + * `jobName:myJob12` + * `jobId:123456` + * `username:abcd100` + * `name:Paul` * If no searchTag used: Best guess search with the following hierarchy - * `jobId -> username -> name -> projectId -> jobName` + * `jobId -> username -> name -> projectId -> jobName` * Destinations: - * JobId: Always Job-Table (Allows multiple identical matches, e.g. JobIds from different clusters) - * JobName: Always Job-Table (Allows multiple identical matches, e.g. JobNames from different clusters) - * ProjectId: Always Job-Table - * Username - * If *one* match found: Opens detailed user-view (`/monitoring/user/$USER`) - * If *multiple* matches found: Opens user-table with matches listed (`/monitoring/users/`) - * **Please Note**: Only users with jobs will be shown in table! I.e., "multiple matches" can still be only one entry in table. - * Name - * If *one* matching username found: Opens detailed user-view (`/monitoring/user/$USER`) - * If *multiple* usernames found: Opens user-table with matches listed (`/monitoring/users/`) - * **Please Note**: Only users with jobs will be shown in table! I.e., "multiple matches" can still be only one entry in table. - * Best guess search always redirects to Job-Table or `/monitoring/user/$USER` (first username match) -* Simple HTML Error if ... - * Best guess search fails -> 'Not Found' - * Query `type` is unknown - * More than two colons in string -> 'malformed' + * JobId: Job-Table (Allows multiple identical matches, e.g. JobIds from different clusters) + * JobName: Job-Table (Allows multiple identical matches, e.g. JobNames from different clusters) + * ProjectId: Job-Table + * Username: Users-Table + * **Please Note**: Only users with jobs will be shown in table! I.e., Users without jobs will be missing in table. + * Name: Users-Table + * **Please Note**: Only users with jobs will be shown in table! I.e., Users without jobs will be missing in table. + * Best guess search always redirects to Job-Table or `/monitoring/user/$USER` (first username match) + * Unprocessable queries will redirect to `/monitoring/jobs/?` * Spaces trimmed (both for searchTag and queryString) - * ` job12` == `job12` - * `projectID : abcd ` == `projectId:abcd` + * ` job12` == `job12` + * `projectID : abcd ` == `projectId:abcd` * `jobName`- and `name-`queries work with a part of the target-string - * `jobName:myjob` for jobName "myjob_cluster1" - * `name:Paul` for name "Paul Atreides" + * `jobName:myjob` for jobName "myjob_cluster1" + * `name:Paul` for name "Paul Atreides" * JobName GQL Query is resolved as matching the query as a part of the whole metaData-JSON in the SQL DB. diff --git a/internal/api/rest.go b/internal/api/rest.go index dc2f3d8..b9f188f 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -76,6 +76,7 @@ func (api *RestApi) MountRoutes(r *mux.Router) { if api.Authentication != nil { r.HandleFunc("/jwt/", api.getJWT).Methods(http.MethodGet) + r.HandleFunc("/roles/", api.getRoles).Methods(http.MethodGet) r.HandleFunc("/users/", api.createUser).Methods(http.MethodPost, http.MethodPut) r.HandleFunc("/users/", api.getUsers).Methods(http.MethodGet) r.HandleFunc("/users/", api.deleteUser).Methods(http.MethodDelete) @@ -177,7 +178,7 @@ func decode(r io.Reader, val interface{}) error { // @router /jobs/ [get] func (api *RestApi) getJobs(rw http.ResponseWriter, r *http.Request) { if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.RoleApi), http.StatusForbidden, rw) + handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) return } @@ -317,7 +318,7 @@ func (api *RestApi) getJobs(rw http.ResponseWriter, r *http.Request) { // @router /jobs/tag_job/{id} [post] func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) { if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.RoleApi), http.StatusForbidden, rw) + handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) return } @@ -382,7 +383,7 @@ func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) { // @router /jobs/start_job/ [post] func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) { if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.RoleApi), http.StatusForbidden, rw) + handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) return } @@ -463,7 +464,7 @@ func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) { // @router /jobs/stop_job/{id} [post] func (api *RestApi) stopJobById(rw http.ResponseWriter, r *http.Request) { if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.RoleApi), http.StatusForbidden, rw) + handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) return } @@ -516,7 +517,7 @@ func (api *RestApi) stopJobById(rw http.ResponseWriter, r *http.Request) { // @router /jobs/stop_job/ [post] func (api *RestApi) stopJobByRequest(rw http.ResponseWriter, r *http.Request) { if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.RoleApi), http.StatusForbidden, rw) + handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) return } @@ -562,7 +563,7 @@ func (api *RestApi) stopJobByRequest(rw http.ResponseWriter, r *http.Request) { // @router /jobs/delete_job/{id} [delete] func (api *RestApi) deleteJobById(rw http.ResponseWriter, r *http.Request) { if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.RoleApi), http.StatusForbidden, rw) + handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) return } @@ -610,7 +611,7 @@ func (api *RestApi) deleteJobById(rw http.ResponseWriter, r *http.Request) { // @router /jobs/delete_job/ [delete] func (api *RestApi) deleteJobByRequest(rw http.ResponseWriter, r *http.Request) { if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.RoleApi), http.StatusForbidden, rw) + handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) return } @@ -666,7 +667,7 @@ func (api *RestApi) deleteJobByRequest(rw http.ResponseWriter, r *http.Request) // @router /jobs/delete_job_before/{ts} [delete] func (api *RestApi) deleteJobBefore(rw http.ResponseWriter, r *http.Request) { if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.RoleApi), http.StatusForbidden, rw) + handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) return } @@ -817,17 +818,26 @@ func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) { return } - username, password, role, name, email := r.FormValue("username"), r.FormValue("password"), r.FormValue("role"), r.FormValue("name"), r.FormValue("email") - if len(password) == 0 && role != auth.RoleApi { + username, password, role, name, email, project := r.FormValue("username"), r.FormValue("password"), r.FormValue("role"), r.FormValue("name"), r.FormValue("email"), r.FormValue("project") + if len(password) == 0 && role != auth.GetRoleString(auth.RoleApi) { http.Error(rw, "Only API users are allowed to have a blank password (login will be impossible)", http.StatusBadRequest) return } + if len(project) != 0 && role != auth.GetRoleString(auth.RoleManager) { + http.Error(rw, "only managers require a project (can be changed later)", http.StatusBadRequest) + return + } else if len(project) == 0 && role == auth.GetRoleString(auth.RoleManager) { + http.Error(rw, "managers require a project to manage (can be changed later)", http.StatusBadRequest) + return + } + if err := api.Authentication.AddUser(&auth.User{ Username: username, Name: name, Password: password, Email: email, + Projects: []string{project}, Roles: []string{role}}); err != nil { http.Error(rw, err.Error(), http.StatusUnprocessableEntity) return @@ -866,6 +876,22 @@ func (api *RestApi) getUsers(rw http.ResponseWriter, r *http.Request) { json.NewEncoder(rw).Encode(users) } +func (api *RestApi) getRoles(rw http.ResponseWriter, r *http.Request) { + user := auth.GetUser(r.Context()) + if !user.HasRole(auth.RoleAdmin) { + http.Error(rw, "only admins are allowed to fetch a list of roles", http.StatusForbidden) + return + } + + roles, err := auth.GetValidRoles(user) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + json.NewEncoder(rw).Encode(roles) +} + func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) { if user := auth.GetUser(r.Context()); !user.HasRole(auth.RoleAdmin) { http.Error(rw, "Only admins are allowed to update a user", http.StatusForbidden) @@ -875,6 +901,8 @@ func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) { // Get Values newrole := r.FormValue("add-role") delrole := r.FormValue("remove-role") + newproj := r.FormValue("add-project") + delproj := r.FormValue("remove-project") // TODO: Handle anything but roles... if newrole != "" { @@ -889,8 +917,20 @@ func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) { return } rw.Write([]byte("Remove Role Success")) + } else if newproj != "" { + if err := api.Authentication.AddProject(r.Context(), mux.Vars(r)["id"], newproj); err != nil { + http.Error(rw, err.Error(), http.StatusUnprocessableEntity) + return + } + rw.Write([]byte("Add Project Success")) + } else if delproj != "" { + if err := api.Authentication.RemoveProject(r.Context(), mux.Vars(r)["id"], delproj); err != nil { + http.Error(rw, err.Error(), http.StatusUnprocessableEntity) + return + } + rw.Write([]byte("Remove Project Success")) } else { - http.Error(rw, "Not Add or Del?", http.StatusInternalServerError) + http.Error(rw, "Not Add or Del [role|project]?", http.StatusInternalServerError) } } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 6e67e8e..aab4c3a 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -9,8 +9,10 @@ import ( "crypto/rand" "encoding/base64" "errors" + "fmt" "net/http" "os" + "strings" "time" "github.com/ClusterCockpit/cc-backend/pkg/log" @@ -18,32 +20,186 @@ import ( "github.com/jmoiron/sqlx" ) -const ( - RoleAdmin string = "admin" - RoleSupport string = "support" - RoleApi string = "api" - RoleUser string = "user" -) +type AuthSource int const ( - AuthViaLocalPassword int8 = 0 - AuthViaLDAP int8 = 1 - AuthViaToken int8 = 2 + AuthViaLocalPassword AuthSource = iota + AuthViaLDAP + AuthViaToken ) type User struct { - Username string `json:"username"` - Password string `json:"-"` - Name string `json:"name"` - Roles []string `json:"roles"` - AuthSource int8 `json:"via"` - Email string `json:"email"` + Username string `json:"username"` + Password string `json:"-"` + Name string `json:"name"` + Roles []string `json:"roles"` + AuthSource AuthSource `json:"via"` + Email string `json:"email"` + Projects []string `json:"projects"` Expiration time.Time } -func (u *User) HasRole(role string) bool { +type Role int + +const ( + RoleAnonymous Role = iota + RoleApi + RoleUser + RoleManager + RoleSupport + RoleAdmin + RoleError +) + +func GetRoleString(roleInt Role) string { + return [6]string{"anonymous", "api", "user", "manager", "support", "admin"}[roleInt] +} + +func getRoleEnum(roleStr string) Role { + switch strings.ToLower(roleStr) { + case "admin": + return RoleAdmin + case "support": + return RoleSupport + case "manager": + return RoleManager + case "user": + return RoleUser + case "api": + return RoleApi + case "anonymous": + return RoleAnonymous + default: + return RoleError + } +} + +func isValidRole(role string) bool { + if getRoleEnum(role) == RoleError { + return false + } + return true +} + +func (u *User) HasValidRole(role string) (hasRole bool, isValid bool) { + if isValidRole(role) { + for _, r := range u.Roles { + if r == role { + return true, true + } + } + return false, true + } + return false, false +} + +func (u *User) HasRole(role Role) bool { for _, r := range u.Roles { - if r == role { + if r == GetRoleString(role) { + return true + } + } + return false +} + +// Role-Arrays are short: performance not impacted by nested loop +func (u *User) HasAnyRole(queryroles []Role) bool { + for _, ur := range u.Roles { + for _, qr := range queryroles { + if ur == GetRoleString(qr) { + return true + } + } + } + return false +} + +// Role-Arrays are short: performance not impacted by nested loop +func (u *User) HasAllRoles(queryroles []Role) bool { + target := len(queryroles) + matches := 0 + for _, ur := range u.Roles { + for _, qr := range queryroles { + if ur == GetRoleString(qr) { + matches += 1 + break + } + } + } + + if matches == target { + return true + } else { + return false + } +} + +// Role-Arrays are short: performance not impacted by nested loop +func (u *User) HasNotRoles(queryroles []Role) bool { + matches := 0 + for _, ur := range u.Roles { + for _, qr := range queryroles { + if ur == GetRoleString(qr) { + matches += 1 + break + } + } + } + + if matches == 0 { + return true + } else { + return false + } +} + +// Called by API endpoint '/roles/' from frontend: Only required for admin config -> Check Admin Role +func GetValidRoles(user *User) ([]string, error) { + var vals []string + if user.HasRole(RoleAdmin) { + for i := RoleApi; i < RoleError; i++ { + vals = append(vals, GetRoleString(i)) + } + return vals, nil + } + + return vals, fmt.Errorf("%s: only admins are allowed to fetch a list of roles", user.Username) +} + +// Called by routerConfig web.page setup in backend: Only requires known user and/or not API user +func GetValidRolesMap(user *User) (map[string]Role, error) { + named := make(map[string]Role) + if user.HasNotRoles([]Role{RoleApi, RoleAnonymous}) { + for i := RoleApi; i < RoleError; i++ { + named[GetRoleString(i)] = i + } + return named, nil + } + return named, fmt.Errorf("Only known users are allowed to fetch a list of roles") +} + +// Find highest role +func (u *User) GetAuthLevel() Role { + if u.HasRole(RoleAdmin) { + return RoleAdmin + } else if u.HasRole(RoleSupport) { + return RoleSupport + } else if u.HasRole(RoleManager) { + return RoleManager + } else if u.HasRole(RoleUser) { + return RoleUser + } else if u.HasRole(RoleApi) { + return RoleApi + } else if u.HasRole(RoleAnonymous) { + return RoleAnonymous + } else { + return RoleError + } +} + +func (u *User) HasProject(project string) bool { + for _, p := range u.Projects { + if p == project { return true } } @@ -145,9 +301,11 @@ func (auth *Authentication) AuthViaSession( } username, _ := session.Values["username"].(string) + projects, _ := session.Values["projects"].([]string) roles, _ := session.Values["roles"].([]string) return &User{ Username: username, + Projects: projects, Roles: roles, AuthSource: -1, }, nil @@ -192,6 +350,7 @@ func (auth *Authentication) Login( session.Options.MaxAge = int(auth.SessionMaxAge.Seconds()) } session.Values["username"] = user.Username + session.Values["projects"] = user.Projects session.Values["roles"] = user.Roles if err := auth.sessionStore.Save(r, rw, session); err != nil { log.Warnf("session save failed: %s", err.Error()) @@ -199,7 +358,7 @@ func (auth *Authentication) Login( return } - log.Infof("login successfull: user: %v (roles: %v)", user.Username, user.Roles) + log.Infof("login successfull: user: %#v (roles: %v, projects: %v)", user.Username, user.Roles, user.Projects) ctx := context.WithValue(r.Context(), ContextUserKey, user) onsuccess.ServeHTTP(rw, r.WithContext(ctx)) return diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 8832b06..81aa4eb 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -1 +1,129 @@ +// Copyright (C) 2022 NHR@FAU, University Erlangen-Nuremberg. +// All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. package auth + +import ( + "testing" +) + +func TestHasValidRole(t *testing.T) { + u := User{Username: "testuser", Roles: []string{"user"}} + + exists, _ := u.HasValidRole("user") + + if !exists { + t.Fatalf(`User{Roles: ["user"]} -> HasValidRole("user"): EXISTS = %v, expected 'true'.`, exists) + } +} + +func TestHasNotValidRole(t *testing.T) { + u := User{Username: "testuser", Roles: []string{"user"}} + + exists, _ := u.HasValidRole("manager") + + if exists { + t.Fatalf(`User{Roles: ["user"]} -> HasValidRole("manager"): EXISTS = %v, expected 'false'.`, exists) + } +} + +func TestHasInvalidRole(t *testing.T) { + u := User{Username: "testuser", Roles: []string{"user"}} + + _, valid := u.HasValidRole("invalid") + + if valid { + t.Fatalf(`User{Roles: ["user"]} -> HasValidRole("invalid"): VALID = %v, expected 'false'.`, valid) + } +} + +func TestHasNotInvalidRole(t *testing.T) { + u := User{Username: "testuser", Roles: []string{"user"}} + + _, valid := u.HasValidRole("user") + + if !valid { + t.Fatalf(`User{Roles: ["user"]} -> HasValidRole("user"): VALID = %v, expected 'true'.`, valid) + } +} + +func TestHasRole(t *testing.T) { + u := User{Username: "testuser", Roles: []string{"user"}} + + exists := u.HasRole(RoleUser) + + if !exists { + t.Fatalf(`User{Roles: ["user"]} -> HasRole(RoleUser): EXISTS = %v, expected 'true'.`, exists) + } +} + +func TestHasNotRole(t *testing.T) { + u := User{Username: "testuser", Roles: []string{"user"}} + + exists := u.HasRole(RoleManager) + + if exists { + t.Fatalf(`User{Roles: ["user"]} -> HasRole(RoleManager): EXISTS = %v, expected 'false'.`, exists) + } +} + +func TestHasAnyRole(t *testing.T) { + u := User{Username: "testuser", Roles: []string{"user", "manager"}} + + result := u.HasAnyRole([]Role{RoleManager, RoleSupport, RoleAdmin}) + + if !result { + t.Fatalf(`User{Roles: ["user", "manager"]} -> HasAnyRole([]Role{RoleManager, RoleSupport, RoleAdmin}): RESULT = %v, expected 'true'.`, result) + } +} + +func TestHasNotAnyRole(t *testing.T) { + u := User{Username: "testuser", Roles: []string{"user", "manager"}} + + result := u.HasAnyRole([]Role{RoleSupport, RoleAdmin}) + + if result { + t.Fatalf(`User{Roles: ["user", "manager"]} -> HasAllRoles([]Role{RoleSupport, RoleAdmin}): RESULT = %v, expected 'false'.`, result) + } +} + +func TestHasAllRoles(t *testing.T) { + u := User{Username: "testuser", Roles: []string{"user", "manager", "support"}} + + result := u.HasAllRoles([]Role{RoleUser, RoleManager, RoleSupport}) + + if !result { + t.Fatalf(`User{Roles: ["user", "manager", "support"]} -> HasAllRoles([]Role{RoleUser, RoleManager, RoleSupport}): RESULT = %v, expected 'true'.`, result) + } +} + +func TestHasNotAllRoles(t *testing.T) { + u := User{Username: "testuser", Roles: []string{"user", "manager"}} + + result := u.HasAllRoles([]Role{RoleUser, RoleManager, RoleSupport}) + + if result { + t.Fatalf(`User{Roles: ["user", "manager"]} -> HasAllRoles([]Role{RoleUser, RoleManager, RoleSupport}): RESULT = %v, expected 'false'.`, result) + } +} + +func TestHasNotRoles(t *testing.T) { + u := User{Username: "testuser", Roles: []string{"user", "manager"}} + + result := u.HasNotRoles([]Role{RoleSupport, RoleAdmin}) + + if !result { + t.Fatalf(`User{Roles: ["user", "manager"]} -> HasNotRoles([]Role{RoleSupport, RoleAdmin}): RESULT = %v, expected 'true'.`, result) + } +} + +func TestHasAllNotRoles(t *testing.T) { + u := User{Username: "testuser", Roles: []string{"user", "manager"}} + + result := u.HasNotRoles([]Role{RoleUser, RoleManager}) + + if result { + t.Fatalf(`User{Roles: ["user", "manager"]} -> HasNotRoles([]Role{RoleUser, RoleManager}): RESULT = %v, expected 'false'.`, result) + } +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 64fe671..7a80c1c 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -146,12 +146,16 @@ func (ja *JWTAuthenticator) Login( if rawroles, ok := claims["roles"].([]interface{}); ok { for _, rr := range rawroles { if r, ok := rr.(string); ok { - roles = append(roles, r) + if isValidRole(r) { + roles = append(roles, r) + } } } } if rawrole, ok := claims["roles"].(string); ok { - roles = append(roles, rawrole) + if isValidRole(rawrole) { + roles = append(roles, rawrole) + } } if user == nil { diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go index 455f393..672f10d 100644 --- a/internal/auth/ldap.go +++ b/internal/auth/ldap.go @@ -164,7 +164,7 @@ func (la *LdapAuthenticator) Sync() error { name := newnames[username] log.Debugf("sync: add %v (name: %v, roles: [user], ldap: true)", username, name) if _, err := la.auth.db.Exec(`INSERT INTO user (username, ldap, name, roles) VALUES (?, ?, ?, ?)`, - username, 1, name, "[\""+RoleUser+"\"]"); err != nil { + username, 1, name, "[\""+GetRoleString(RoleUser)+"\"]"); err != nil { log.Errorf("User '%s' new in LDAP: Insert into DB failed", username) return err } diff --git a/internal/auth/users.go b/internal/auth/users.go index 0b358d0..b69533b 100644 --- a/internal/auth/users.go +++ b/internal/auth/users.go @@ -10,6 +10,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "github.com/ClusterCockpit/cc-backend/internal/graph/model" "github.com/ClusterCockpit/cc-backend/pkg/log" @@ -21,10 +22,10 @@ import ( func (auth *Authentication) GetUser(username string) (*User, error) { user := &User{Username: username} - var hashedPassword, name, rawRoles, email sql.NullString - if err := sq.Select("password", "ldap", "name", "roles", "email").From("user"). + var hashedPassword, name, rawRoles, email, rawProjects sql.NullString + if err := sq.Select("password", "ldap", "name", "roles", "email", "projects").From("user"). Where("user.username = ?", username).RunWith(auth.db). - QueryRow().Scan(&hashedPassword, &user.AuthSource, &name, &rawRoles, &email); err != nil { + QueryRow().Scan(&hashedPassword, &user.AuthSource, &name, &rawRoles, &email, &rawProjects); err != nil { log.Warnf("Error while querying user '%v' from database", username) return nil, err } @@ -38,6 +39,11 @@ func (auth *Authentication) GetUser(username string) (*User, error) { return nil, err } } + if rawProjects.Valid { + if err := json.Unmarshal([]byte(rawProjects.String), &user.Projects); err != nil { + return nil, err + } + } return user, nil } @@ -45,9 +51,11 @@ func (auth *Authentication) GetUser(username string) (*User, error) { func (auth *Authentication) AddUser(user *User) error { rolesJson, _ := json.Marshal(user.Roles) + projectsJson, _ := json.Marshal(user.Projects) + + cols := []string{"username", "roles", "projects"} + vals := []interface{}{user.Username, string(rolesJson), string(projectsJson)} - cols := []string{"username", "roles"} - vals := []interface{}{user.Username, string(rolesJson)} if user.Name != "" { cols = append(cols, "name") vals = append(vals, user.Name) @@ -71,7 +79,7 @@ func (auth *Authentication) AddUser(user *User) error { return err } - log.Infof("new user %v created (roles: %s, auth-source: %d)", user.Username, rolesJson, user.AuthSource) + log.Infof("new user %#v created (roles: %s, auth-source: %d, projects: %s)", user.Username, rolesJson, user.AuthSource, projectsJson) return nil } @@ -84,7 +92,7 @@ func (auth *Authentication) DelUser(username string) error { func (auth *Authentication) ListUsers(specialsOnly bool) ([]*User, error) { - q := sq.Select("username", "name", "email", "roles").From("user") + q := sq.Select("username", "name", "email", "roles", "projects").From("user") if specialsOnly { q = q.Where("(roles != '[\"user\"]' AND roles != '[]')") } @@ -99,9 +107,10 @@ func (auth *Authentication) ListUsers(specialsOnly bool) ([]*User, error) { defer rows.Close() for rows.Next() { rawroles := "" + rawprojects := "" user := &User{} var name, email sql.NullString - if err := rows.Scan(&user.Username, &name, &email, &rawroles); err != nil { + if err := rows.Scan(&user.Username, &name, &email, &rawroles, &rawprojects); err != nil { log.Warn("Error while scanning user list") return nil, err } @@ -111,6 +120,10 @@ func (auth *Authentication) ListUsers(specialsOnly bool) ([]*User, error) { return nil, err } + if err := json.Unmarshal([]byte(rawprojects), &user.Projects); err != nil { + return nil, err + } + user.Name = name.String user.Email = email.String users = append(users, user) @@ -121,25 +134,25 @@ func (auth *Authentication) ListUsers(specialsOnly bool) ([]*User, error) { func (auth *Authentication) AddRole( ctx context.Context, username string, - role string) error { + queryrole string) error { + newRole := strings.ToLower(queryrole) user, err := auth.GetUser(username) if err != nil { log.Warnf("Could not load user '%s'", username) return err } - if role != RoleAdmin && role != RoleApi && role != RoleUser && role != RoleSupport { - return fmt.Errorf("Invalid user role: %v", role) + exists, valid := user.HasValidRole(newRole) + + if !valid { + return fmt.Errorf("Supplied role is no valid option : %v", newRole) + } + if exists { + return fmt.Errorf("User %v already has role %v", username, newRole) } - for _, r := range user.Roles { - if r == role { - return fmt.Errorf("User %v already has role %v", username, role) - } - } - - roles, _ := json.Marshal(append(user.Roles, role)) + roles, _ := json.Marshal(append(user.Roles, newRole)) if _, err := sq.Update("user").Set("roles", roles).Where("user.username = ?", username).RunWith(auth.db).Exec(); err != nil { log.Errorf("Error while adding new role for user '%s'", user.Username) return err @@ -147,42 +160,111 @@ func (auth *Authentication) AddRole( return nil } -func (auth *Authentication) RemoveRole(ctx context.Context, username string, role string) error { +func (auth *Authentication) RemoveRole(ctx context.Context, username string, queryrole string) error { + oldRole := strings.ToLower(queryrole) user, err := auth.GetUser(username) if err != nil { log.Warnf("Could not load user '%s'", username) return err } - if role != RoleAdmin && role != RoleApi && role != RoleUser && role != RoleSupport { - return fmt.Errorf("Invalid user role: %v", role) + exists, valid := user.HasValidRole(oldRole) + + if !valid { + return fmt.Errorf("Supplied role is no valid option : %v", oldRole) + } + if !exists { + return fmt.Errorf("Role already deleted for user '%v': %v", username, oldRole) + } + + if oldRole == GetRoleString(RoleManager) && len(user.Projects) != 0 { + return fmt.Errorf("Cannot remove role 'manager' while user %s still has assigned project(s) : %v", username, user.Projects) + } + + var newroles []string + for _, r := range user.Roles { + if r != oldRole { + newroles = append(newroles, r) // Append all roles not matching requested to be deleted role + } + } + + var mroles, _ = json.Marshal(newroles) + if _, err := sq.Update("user").Set("roles", mroles).Where("user.username = ?", username).RunWith(auth.db).Exec(); err != nil { + log.Errorf("Error while removing role for user '%s'", user.Username) + return err + } + return nil +} + +func (auth *Authentication) AddProject( + ctx context.Context, + username string, + project string) error { + + user, err := auth.GetUser(username) + if err != nil { + return err + } + + if !user.HasRole(RoleManager) { + return fmt.Errorf("user '%s' is not a manager!", username) + } + + if user.HasProject(project) { + return fmt.Errorf("user '%s' already manages project '%s'", username, project) + } + + projects, _ := json.Marshal(append(user.Projects, project)) + if _, err := sq.Update("user").Set("projects", projects).Where("user.username = ?", username).RunWith(auth.db).Exec(); err != nil { + return err + } + + return nil +} + +func (auth *Authentication) RemoveProject(ctx context.Context, username string, project string) error { + user, err := auth.GetUser(username) + if err != nil { + return err + } + + if !user.HasRole(RoleManager) { + return fmt.Errorf("user '%#v' is not a manager!", username) + } + + if !user.HasProject(project) { + return fmt.Errorf("user '%#v': Cannot remove project '%#v' - Does not match!", username, project) } var exists bool - var newroles []string - for _, r := range user.Roles { - if r != role { - newroles = append(newroles, r) // Append all roles not matching requested delete role + var newprojects []string + for _, p := range user.Projects { + if p != project { + newprojects = append(newprojects, p) // Append all projects not matching requested to be deleted project } else { exists = true } } - if (exists == true) { - var mroles, _ = json.Marshal(newroles) - if _, err := sq.Update("user").Set("roles", mroles).Where("user.username = ?", username).RunWith(auth.db).Exec(); err != nil { - log.Errorf("Error while removing role for user '%s'", user.Username) + if exists == true { + var result interface{} + if len(newprojects) == 0 { + result = "[]" + } else { + result, _ = json.Marshal(newprojects) + } + if _, err := sq.Update("user").Set("projects", result).Where("user.username = ?", username).RunWith(auth.db).Exec(); err != nil { return err } return nil } else { - return fmt.Errorf("User '%v' already does not have role: %v", username, role) + return fmt.Errorf("user %s already does not manage project %s", username, project) } } func FetchUser(ctx context.Context, db *sqlx.DB, username string) (*model.User, error) { me := GetUser(ctx) - if me != nil && !me.HasRole(RoleAdmin) && !me.HasRole(RoleSupport) && me.Username != username { + if me != nil && me.Username != username && me.HasNotRoles([]Role{RoleAdmin, RoleSupport, RoleManager}) { return nil, errors.New("forbidden") } diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index 8275681..c119763 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -170,7 +170,7 @@ func (r *queryResolver) Job(ctx context.Context, id string) (*schema.Job, error) return nil, err } - if user := auth.GetUser(ctx); user != nil && !user.HasRole(auth.RoleAdmin) && !user.HasRole(auth.RoleSupport) && job.User != user.Username { + if user := auth.GetUser(ctx); user != nil && job.User != user.Username && user.HasNotRoles([]auth.Role{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { return nil, errors.New("you are not allowed to see this job") } diff --git a/internal/repository/job.go b/internal/repository/job.go index e1e70b9..6db62c0 100644 --- a/internal/repository/job.go +++ b/internal/repository/job.go @@ -356,8 +356,12 @@ func (r *JobRepository) CountGroupedJobs(ctx context.Context, aggreg model.Aggre } } - q := sq.Select("job."+string(aggreg), count).From("job").GroupBy("job." + string(aggreg)).OrderBy("count DESC") - q = SecurityCheck(ctx, q) + q, qerr := SecurityCheck(ctx, sq.Select("job."+string(aggreg), count).From("job").GroupBy("job."+string(aggreg)).OrderBy("count DESC")) + + if qerr != nil { + return nil, qerr + } + for _, f := range filters { q = BuildWhereClause(f, q) } @@ -488,195 +492,94 @@ var ErrForbidden = errors.New("not authorized") // If query is found to be an integer (= conversion to INT datatype succeeds), skip back to parent call // If nothing matches the search, `ErrNotFound` is returned. -func (r *JobRepository) FindJobnameOrUserOrProject(ctx context.Context, searchterm string) (metasnip string, username string, project string, err error) { - user := auth.GetUser(ctx) +func (r *JobRepository) FindUserOrProjectOrJobname(ctx context.Context, searchterm string) (username string, project string, metasnip string, err error) { if _, err := strconv.Atoi(searchterm); err == nil { // Return empty on successful conversion: parent method will redirect for integer jobId return "", "", "", nil - } else { // has to have letters - - if user == nil || user.HasRole(auth.RoleAdmin) || user.HasRole(auth.RoleSupport) { - err := sq.Select("job.user").Distinct().From("job"). - Where("job.user = ?", searchterm). - RunWith(r.stmtCache).QueryRow().Scan(&username) + } else { // Has to have letters and logged-in user for other guesses + user := auth.GetUser(ctx) + if user != nil { + // Find username in jobs (match) + uresult, _ := r.FindColumnValue(user, searchterm, "job", "user", "user", false) + if uresult != "" { + return uresult, "", "", nil + } + // Find username by name (like) + nresult, _ := r.FindColumnValue(user, searchterm, "user", "username", "name", true) + if nresult != "" { + return nresult, "", "", nil + } + // Find projectId in jobs (match) + presult, _ := r.FindColumnValue(user, searchterm, "job", "project", "project", false) + if presult != "" { + return "", presult, "", nil + } + // Still no return (or not authorized for above): Try JobName + // Match Metadata, on hit, parent method redirects to jobName GQL query + err := sq.Select("job.cluster").Distinct().From("job"). + Where("job.meta_data LIKE ?", "%"+searchterm+"%"). + RunWith(r.stmtCache).QueryRow().Scan(&metasnip) if err != nil && err != sql.ErrNoRows { return "", "", "", err } else if err == nil { - return "", username, "", nil - } - - if username == "" { // Try with Name2Username query - errtwo := sq.Select("user.username").Distinct().From("user"). - Where("user.name LIKE ?", fmt.Sprint("%"+searchterm+"%")). - RunWith(r.stmtCache).QueryRow().Scan(&username) - if errtwo != nil && errtwo != sql.ErrNoRows { - return "", "", "", errtwo - } else if errtwo == nil { - return "", username, "", nil - } + return "", "", metasnip[0:1], nil } } - - if user == nil || user.HasRole(auth.RoleAdmin) || user.HasRole(auth.RoleSupport) { - err := sq.Select("job.project").Distinct().From("job"). - Where("job.project = ?", searchterm). - RunWith(r.stmtCache).QueryRow().Scan(&project) - if err != nil && err != sql.ErrNoRows { - return "", "", "", err - } else if err == nil { - return "", "", project, nil - } - } - - // All Authorizations: If unlabeled query not username or projectId, try for jobname: Match Metadata, on hit, parent method redirects to jobName GQL query - err := sq.Select("job.cluster").Distinct().From("job"). - Where("job.meta_data LIKE ?", "%"+searchterm+"%"). - RunWith(r.stmtCache).QueryRow().Scan(&metasnip) - if err != nil && err != sql.ErrNoRows { - return "", "", "", err - } else if err == nil { - return metasnip[0:1], "", "", nil - } - return "", "", "", ErrNotFound } } -func (r *JobRepository) FindUser(ctx context.Context, searchterm string) (username string, err error) { - user := auth.GetUser(ctx) - if user == nil || user.HasRole(auth.RoleAdmin) || user.HasRole(auth.RoleSupport) { - err := sq.Select("job.user").Distinct().From("job"). - Where("job.user = ?", searchterm). - RunWith(r.stmtCache).QueryRow().Scan(&username) +func (r *JobRepository) FindColumnValue(user *auth.User, searchterm string, table string, selectColumn string, whereColumn string, isLike bool) (result string, err error) { + compareStr := " = ?" + query := searchterm + if isLike == true { + compareStr = " LIKE ?" + query = "%" + searchterm + "%" + } + if user.HasAnyRole([]auth.Role{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { + err := sq.Select(table+"."+selectColumn).Distinct().From(table). + Where(table+"."+whereColumn+compareStr, query). + RunWith(r.stmtCache).QueryRow().Scan(&result) if err != nil && err != sql.ErrNoRows { return "", err } else if err == nil { - return username, nil + return result, nil } return "", ErrNotFound - } else { - log.Infof("Non-Admin User %s : Requested Query Username -> %s: Forbidden", user.Name, searchterm) + log.Infof("Non-Admin User %s : Requested Query '%s' on table '%s' : Forbidden", user.Name, query, table) return "", ErrForbidden } } -func (r *JobRepository) FindUserByName(ctx context.Context, searchterm string) (username string, err error) { - user := auth.GetUser(ctx) - if user == nil || user.HasRole(auth.RoleAdmin) || user.HasRole(auth.RoleSupport) { - err := sq.Select("user.username").Distinct().From("user"). - Where("user.name = ?", searchterm). - RunWith(r.stmtCache).QueryRow().Scan(&username) - if err != nil && err != sql.ErrNoRows { - return "", err - } else if err == nil { - return username, nil - } - return "", ErrNotFound - - } else { - log.Infof("Non-Admin User %s : Requested Query Name -> %s: Forbidden", user.Name, searchterm) - return "", ErrForbidden - } -} - -func (r *JobRepository) FindUsers(ctx context.Context, searchterm string) (usernames []string, err error) { - user := auth.GetUser(ctx) +func (r *JobRepository) FindColumnValues(user *auth.User, query string, table string, selectColumn string, whereColumn string) (results []string, err error) { emptyResult := make([]string, 0) - if user == nil || user.HasRole(auth.RoleAdmin) || user.HasRole(auth.RoleSupport) { - rows, err := sq.Select("job.user").Distinct().From("job"). - Where("job.user LIKE ?", fmt.Sprint("%", searchterm, "%")). + if user.HasAnyRole([]auth.Role{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { + rows, err := sq.Select(table+"."+selectColumn).Distinct().From(table). + Where(table+"."+whereColumn+" LIKE ?", fmt.Sprint("%", query, "%")). RunWith(r.stmtCache).Query() if err != nil && err != sql.ErrNoRows { return emptyResult, err } else if err == nil { for rows.Next() { - var name string - err := rows.Scan(&name) + var result string + err := rows.Scan(&result) if err != nil { rows.Close() log.Warnf("Error while scanning rows: %v", err) return emptyResult, err } - usernames = append(usernames, name) + results = append(results, result) } - return usernames, nil + return results, nil } return emptyResult, ErrNotFound } else { - log.Infof("Non-Admin User %s : Requested Query Usernames -> %s: Forbidden", user.Name, searchterm) + log.Infof("Non-Admin User %s : Requested Query '%s' on table '%s' : Forbidden", user.Name, query, table) return emptyResult, ErrForbidden } } -func (r *JobRepository) FindUsersByName(ctx context.Context, searchterm string) (usernames []string, err error) { - user := auth.GetUser(ctx) - emptyResult := make([]string, 0) - if user == nil || user.HasRole(auth.RoleAdmin) || user.HasRole(auth.RoleSupport) { - rows, err := sq.Select("user.username").Distinct().From("user"). - Where("user.name LIKE ?", fmt.Sprint("%", searchterm, "%")). - RunWith(r.stmtCache).Query() - if err != nil && err != sql.ErrNoRows { - return emptyResult, err - } else if err == nil { - for rows.Next() { - var username string - err := rows.Scan(&username) - if err != nil { - rows.Close() - log.Warnf("Error while scanning rows: %v", err) - return emptyResult, err - } - usernames = append(usernames, username) - } - return usernames, nil - } - return emptyResult, ErrNotFound - - } else { - log.Infof("Non-Admin User %s : Requested Query name -> %s: Forbidden", user.Name, searchterm) - return emptyResult, ErrForbidden - } -} - -func (r *JobRepository) FindNameByUser(ctx context.Context, searchterm string) (name string, err error) { - user := auth.GetUser(ctx) - if user == nil || user.HasRole(auth.RoleAdmin) || user.HasRole(auth.RoleSupport) { - err := sq.Select("user.name").Distinct().From("user"). - Where("user.username = ?", searchterm). - RunWith(r.stmtCache).QueryRow().Scan(&name) - if err != nil && err != sql.ErrNoRows { - return "", err - } else if err == nil { - return name, nil - } - return "", ErrNotFound - - } else { - log.Infof("Non-Admin User %s : Requested Query Name -> %s: Forbidden", user.Name, searchterm) - return "", ErrForbidden - } -} - -func (r *JobRepository) FindProject(ctx context.Context, searchterm string) (project string, err error) { - - user := auth.GetUser(ctx) - if user == nil || user.HasRole(auth.RoleAdmin) || user.HasRole(auth.RoleSupport) { - err := sq.Select("job.project").Distinct().From("job"). - Where("job.project = ?", searchterm). - RunWith(r.stmtCache).QueryRow().Scan(&project) - if err != nil && err != sql.ErrNoRows { - return "", err - } else if err == nil { - return project, nil - } - return "", ErrNotFound - } else { - log.Infof("Non-Admin User %s : Requested Query Project -> %s: Forbidden", user.Name, project) - return "", ErrForbidden - } -} - func (r *JobRepository) Partitions(cluster string) ([]string, error) { var err error start := time.Now() @@ -796,9 +699,9 @@ func (r *JobRepository) JobsStatistics(ctx context.Context, for _, cluster := range archive.Clusters { for _, subcluster := range cluster.SubClusters { corehoursCol := fmt.Sprintf("CAST(ROUND(SUM(job.duration * job.num_nodes * %d * %d) / 3600) as %s)", subcluster.SocketsPerNode, subcluster.CoresPerSocket, castType) - var query sq.SelectBuilder + var rawQuery sq.SelectBuilder if groupBy == nil { - query = sq.Select( + rawQuery = sq.Select( "''", "COUNT(job.id)", fmt.Sprintf("CAST(ROUND(SUM(job.duration) / 3600) as %s)", castType), @@ -806,7 +709,7 @@ func (r *JobRepository) JobsStatistics(ctx context.Context, ).From("job") } else { col := groupBy2column[*groupBy] - query = sq.Select( + rawQuery = sq.Select( col, "COUNT(job.id)", fmt.Sprintf("CAST(ROUND(SUM(job.duration) / 3600) as %s)", castType), @@ -814,11 +717,16 @@ func (r *JobRepository) JobsStatistics(ctx context.Context, ).From("job").GroupBy(col) } - query = query. + rawQuery = rawQuery. Where("job.cluster = ?", cluster.Name). Where("job.subcluster = ?", subcluster.Name) - query = SecurityCheck(ctx, query) + query, qerr := SecurityCheck(ctx, rawQuery) + + if qerr != nil { + return nil, qerr + } + for _, f := range filter { query = BuildWhereClause(f, query) } @@ -857,8 +765,14 @@ func (r *JobRepository) JobsStatistics(ctx context.Context, } if groupBy == nil { + query := sq.Select("COUNT(job.id)").From("job").Where("job.duration < ?", config.Keys.ShortRunningJobsDuration) - query = SecurityCheck(ctx, query) + query, qerr := SecurityCheck(ctx, query) + + if qerr != nil { + return nil, qerr + } + for _, f := range filter { query = BuildWhereClause(f, query) } @@ -868,8 +782,15 @@ func (r *JobRepository) JobsStatistics(ctx context.Context, } } else { col := groupBy2column[*groupBy] + query := sq.Select(col, "COUNT(job.id)").From("job").Where("job.duration < ?", config.Keys.ShortRunningJobsDuration) - query = SecurityCheck(ctx, query) + + query, qerr := SecurityCheck(ctx, query) + + if qerr != nil { + return nil, qerr + } + for _, f := range filter { query = BuildWhereClause(f, query) } @@ -895,7 +816,8 @@ func (r *JobRepository) JobsStatistics(ctx context.Context, if col == "job.user" { for id := range stats { emptyDash := "-" - name, _ := r.FindNameByUser(ctx, id) + user := auth.GetUser(ctx) + name, _ := r.FindColumnValue(user, id, "user", "name", "username", false) if name != "" { stats[id].Name = &name } else { @@ -952,7 +874,12 @@ func (r *JobRepository) jobsStatisticsHistogram(ctx context.Context, start := time.Now() query := sq.Select(value, "COUNT(job.id) AS count").From("job") - query = SecurityCheck(ctx, query) + query, qerr := SecurityCheck(ctx, sq.Select(value, "COUNT(job.id) AS count").From("job")) + + if qerr != nil { + return nil, qerr + } + for _, f := range filters { query = BuildWhereClause(f, query) } diff --git a/internal/repository/migration.go b/internal/repository/migration.go index 934a002..dd30272 100644 --- a/internal/repository/migration.go +++ b/internal/repository/migration.go @@ -17,7 +17,7 @@ import ( "github.com/golang-migrate/migrate/v4/source/iofs" ) -const supportedVersion uint = 2 +const supportedVersion uint = 3 //go:embed migrations/* var migrationFiles embed.FS diff --git a/internal/repository/migrations/mysql/01_init-schema.down.sql b/internal/repository/migrations/mysql/01_init-schema.down.sql index 58617e1..68da646 100644 --- a/internal/repository/migrations/mysql/01_init-schema.down.sql +++ b/internal/repository/migrations/mysql/01_init-schema.down.sql @@ -2,4 +2,4 @@ DROP TABLE IF EXISTS job; DROP TABLE IF EXISTS tags; DROP TABLE IF EXISTS jobtag; DROP TABLE IF EXISTS configuration; -DROP TABLE IF EXISTS user; \ No newline at end of file +DROP TABLE IF EXISTS user; diff --git a/internal/repository/migrations/mysql/02_add-index.down.sql b/internal/repository/migrations/mysql/02_add-index.down.sql index 6c67b45..8129772 100644 --- a/internal/repository/migrations/mysql/02_add-index.down.sql +++ b/internal/repository/migrations/mysql/02_add-index.down.sql @@ -2,4 +2,4 @@ DROP INDEX IF EXISTS job_stats; DROP INDEX IF EXISTS job_by_user; DROP INDEX IF EXISTS job_by_starttime; DROP INDEX IF EXISTS job_by_job_id; -DROP INDEX IF EXISTS job_by_state; \ No newline at end of file +DROP INDEX IF EXISTS job_by_state; diff --git a/internal/repository/migrations/mysql/02_add-index.up.sql b/internal/repository/migrations/mysql/02_add-index.up.sql index ed7d79f..7d8d04a 100644 --- a/internal/repository/migrations/mysql/02_add-index.up.sql +++ b/internal/repository/migrations/mysql/02_add-index.up.sql @@ -2,4 +2,4 @@ CREATE INDEX IF NOT EXISTS job_stats ON job (cluster,subcluster,user); CREATE INDEX IF NOT EXISTS job_by_user ON job (user); CREATE INDEX IF NOT EXISTS job_by_starttime ON job (start_time); CREATE INDEX IF NOT EXISTS job_by_job_id ON job (job_id); -CREATE INDEX IF NOT EXISTS job_by_state ON job (job_state); \ No newline at end of file +CREATE INDEX IF NOT EXISTS job_by_state ON job (job_state); diff --git a/internal/repository/migrations/mysql/03_add-userprojects.down.sql b/internal/repository/migrations/mysql/03_add-userprojects.down.sql new file mode 100644 index 0000000..bbf1e64 --- /dev/null +++ b/internal/repository/migrations/mysql/03_add-userprojects.down.sql @@ -0,0 +1 @@ +ALTER TABLE user DROP COLUMN projects; diff --git a/internal/repository/migrations/mysql/03_add-userprojects.up.sql b/internal/repository/migrations/mysql/03_add-userprojects.up.sql new file mode 100644 index 0000000..d0f19c2 --- /dev/null +++ b/internal/repository/migrations/mysql/03_add-userprojects.up.sql @@ -0,0 +1 @@ +ALTER TABLE user ADD COLUMN projects varchar(255) NOT NULL DEFAULT "[]"; diff --git a/internal/repository/migrations/sqlite3/01_init-schema.down.sql b/internal/repository/migrations/sqlite3/01_init-schema.down.sql index 58617e1..68da646 100644 --- a/internal/repository/migrations/sqlite3/01_init-schema.down.sql +++ b/internal/repository/migrations/sqlite3/01_init-schema.down.sql @@ -2,4 +2,4 @@ DROP TABLE IF EXISTS job; DROP TABLE IF EXISTS tags; DROP TABLE IF EXISTS jobtag; DROP TABLE IF EXISTS configuration; -DROP TABLE IF EXISTS user; \ No newline at end of file +DROP TABLE IF EXISTS user; diff --git a/internal/repository/migrations/sqlite3/02_add-index.down.sql b/internal/repository/migrations/sqlite3/02_add-index.down.sql index 6c67b45..8129772 100644 --- a/internal/repository/migrations/sqlite3/02_add-index.down.sql +++ b/internal/repository/migrations/sqlite3/02_add-index.down.sql @@ -2,4 +2,4 @@ DROP INDEX IF EXISTS job_stats; DROP INDEX IF EXISTS job_by_user; DROP INDEX IF EXISTS job_by_starttime; DROP INDEX IF EXISTS job_by_job_id; -DROP INDEX IF EXISTS job_by_state; \ No newline at end of file +DROP INDEX IF EXISTS job_by_state; diff --git a/internal/repository/migrations/sqlite3/02_add-index.up.sql b/internal/repository/migrations/sqlite3/02_add-index.up.sql index ed7d79f..7d8d04a 100644 --- a/internal/repository/migrations/sqlite3/02_add-index.up.sql +++ b/internal/repository/migrations/sqlite3/02_add-index.up.sql @@ -2,4 +2,4 @@ CREATE INDEX IF NOT EXISTS job_stats ON job (cluster,subcluster,user); CREATE INDEX IF NOT EXISTS job_by_user ON job (user); CREATE INDEX IF NOT EXISTS job_by_starttime ON job (start_time); CREATE INDEX IF NOT EXISTS job_by_job_id ON job (job_id); -CREATE INDEX IF NOT EXISTS job_by_state ON job (job_state); \ No newline at end of file +CREATE INDEX IF NOT EXISTS job_by_state ON job (job_state); diff --git a/internal/repository/migrations/sqlite3/03_add-userprojects.down.sql b/internal/repository/migrations/sqlite3/03_add-userprojects.down.sql new file mode 100644 index 0000000..bbf1e64 --- /dev/null +++ b/internal/repository/migrations/sqlite3/03_add-userprojects.down.sql @@ -0,0 +1 @@ +ALTER TABLE user DROP COLUMN projects; diff --git a/internal/repository/migrations/sqlite3/03_add-userprojects.up.sql b/internal/repository/migrations/sqlite3/03_add-userprojects.up.sql new file mode 100644 index 0000000..d0f19c2 --- /dev/null +++ b/internal/repository/migrations/sqlite3/03_add-userprojects.up.sql @@ -0,0 +1 @@ +ALTER TABLE user ADD COLUMN projects varchar(255) NOT NULL DEFAULT "[]"; diff --git a/internal/repository/query.go b/internal/repository/query.go index 183b89b..fd90faf 100644 --- a/internal/repository/query.go +++ b/internal/repository/query.go @@ -26,8 +26,11 @@ func (r *JobRepository) QueryJobs( page *model.PageRequest, order *model.OrderByInput) ([]*schema.Job, error) { - query := sq.Select(jobColumns...).From("job") - query = SecurityCheck(ctx, query) + query, qerr := SecurityCheck(ctx, sq.Select(jobColumns...).From("job")) + + if qerr != nil { + return nil, qerr + } if order != nil { field := toSnakeCase(order.Field) @@ -82,8 +85,12 @@ func (r *JobRepository) CountJobs( filters []*model.JobFilter) (int, error) { // count all jobs: - query := sq.Select("count(*)").From("job") - query = SecurityCheck(ctx, query) + query, qerr := SecurityCheck(ctx, sq.Select("count(*)").From("job")) + + if qerr != nil { + return 0, qerr + } + for _, f := range filters { query = BuildWhereClause(f, query) } @@ -95,13 +102,23 @@ func (r *JobRepository) CountJobs( return count, nil } -func SecurityCheck(ctx context.Context, query sq.SelectBuilder) sq.SelectBuilder { +func SecurityCheck(ctx context.Context, query sq.SelectBuilder) (queryOut sq.SelectBuilder, err error) { user := auth.GetUser(ctx) - if user == nil || user.HasRole(auth.RoleAdmin) || user.HasRole(auth.RoleApi) || user.HasRole(auth.RoleSupport) { - return query + if user == nil || user.HasAnyRole([]auth.Role{auth.RoleAdmin, auth.RoleSupport, auth.RoleApi}) { // Admin & Co. : All jobs + return query, nil + } else if user.HasRole(auth.RoleManager) { // Manager : Add filter for managed projects' jobs only + personal jobs + if len(user.Projects) != 0 { + return query.Where(sq.Or{sq.Eq{"job.project": user.Projects}, sq.Eq{"job.user": user.Username}}), nil + } else { + log.Infof("Manager-User '%s' has no defined projects to lookup! Query only personal jobs ...", user.Username) + return query.Where("job.user = ?", user.Username), nil + } + } else if user.HasRole(auth.RoleUser) { // User : Only personal jobs + return query.Where("job.user = ?", user.Username), nil + } else { // Unauthorized : Error + var qnil sq.SelectBuilder + return qnil, errors.New(fmt.Sprintf("User '%s' with unknown roles! [%#v]\n", user.Username, user.Roles)) } - - return query.Where("job.user = ?", user.Username) } // Build a sq.SelectBuilder out of a schema.JobFilter. diff --git a/internal/repository/tags.go b/internal/repository/tags.go index c8795e1..ce076ff 100644 --- a/internal/repository/tags.go +++ b/internal/repository/tags.go @@ -5,9 +5,12 @@ package repository import ( + "strings" + + "github.com/ClusterCockpit/cc-backend/internal/auth" "github.com/ClusterCockpit/cc-backend/pkg/archive" - "github.com/ClusterCockpit/cc-backend/pkg/schema" "github.com/ClusterCockpit/cc-backend/pkg/log" + "github.com/ClusterCockpit/cc-backend/pkg/schema" sq "github.com/Masterminds/squirrel" ) @@ -65,7 +68,7 @@ func (r *JobRepository) CreateTag(tagType string, tagName string) (tagId int64, return res.LastInsertId() } -func (r *JobRepository) CountTags(user *string) (tags []schema.Tag, counts map[string]int, err error) { +func (r *JobRepository) CountTags(user *auth.User) (tags []schema.Tag, counts map[string]int, err error) { tags = make([]schema.Tag, 0, 100) xrows, err := r.DB.Queryx("SELECT * FROM tag") if err != nil { @@ -84,9 +87,13 @@ func (r *JobRepository) CountTags(user *string) (tags []schema.Tag, counts map[s From("tag t"). LeftJoin("jobtag jt ON t.id = jt.tag_id"). GroupBy("t.tag_name") - if user != nil { - q = q.Where("jt.job_id IN (SELECT id FROM job WHERE job.user = ?)", *user) - } + + if user != nil && user.HasRole(auth.RoleUser) { // USER: Only count own jobs + q = q.Where("jt.job_id IN (SELECT id FROM job WHERE job.user = ?)", user.Username) + } else if user != nil && user.HasRole(auth.RoleManager) { // MANAGER: Count own jobs plus project's jobs + // Build ("project1", "project2", ...) list of variable length directly in SQL string + q = q.Where("jt.job_id IN (SELECT id FROM job WHERE job.user = ? OR job.project IN (\""+strings.Join(user.Projects, "\",\"")+"\"))", user.Username) + } // else: ADMIN || SUPPORT: Count all jobs rows, err := q.RunWith(r.stmtCache).Query() if err != nil { diff --git a/internal/routerConfig/routes.go b/internal/routerConfig/routes.go index 57138bc..87d2c0e 100644 --- a/internal/routerConfig/routes.go +++ b/internal/routerConfig/routes.go @@ -104,6 +104,7 @@ func setupUserRoute(i InfoType, r *http.Request) InfoType { username := mux.Vars(r)["id"] i["id"] = username i["username"] = username + // TODO: If forbidden (== err exists), redirect to error page if user, _ := auth.FetchUser(r.Context(), jobRepo.DB, username); user != nil { i["name"] = user.Name i["email"] = user.Email @@ -142,13 +143,10 @@ func setupAnalysisRoute(i InfoType, r *http.Request) InfoType { } func setupTaglistRoute(i InfoType, r *http.Request) InfoType { - var username *string = nil jobRepo := repository.GetJobRepository() - if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleAdmin) { - username = &user.Username - } + user := auth.GetUser(r.Context()) - tags, counts, err := jobRepo.CountTags(username) + tags, counts, err := jobRepo.CountTags(user) tagMap := make(map[string][]map[string]interface{}) if err != nil { log.Warnf("GetTags failed: %s", err.Error()) @@ -279,17 +277,15 @@ func SetupRoutes(router *mux.Router, version string, hash string, buildTime stri title = strings.Replace(route.Title, "", id.(string), 1) } - username, isAdmin, isSupporter := "", true, true - - if user := auth.GetUser(r.Context()); user != nil { - username = user.Username - isAdmin = user.HasRole(auth.RoleAdmin) - isSupporter = user.HasRole(auth.RoleSupport) - } + // Get User -> What if NIL? + user := auth.GetUser(r.Context()) + // Get Roles + availableRoles, _ := auth.GetValidRolesMap(user) page := web.Page{ Title: title, - User: web.User{Username: username, IsAdmin: isAdmin, IsSupporter: isSupporter}, + User: *user, + Roles: availableRoles, Build: web.Build{Version: version, Hash: hash, Buildtime: buildTime}, Config: conf, Infos: infos, @@ -306,75 +302,61 @@ func SetupRoutes(router *mux.Router, version string, hash string, buildTime stri func HandleSearchBar(rw http.ResponseWriter, r *http.Request, api *api.RestApi) { if search := r.URL.Query().Get("searchId"); search != "" { + user := auth.GetUser(r.Context()) splitSearch := strings.Split(search, ":") if len(splitSearch) == 2 { switch strings.Trim(splitSearch[0], " ") { case "jobId": http.Redirect(rw, r, "/monitoring/jobs/?jobId="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusTemporaryRedirect) // All Users: Redirect to Tablequery - return case "jobName": http.Redirect(rw, r, "/monitoring/jobs/?jobName="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusTemporaryRedirect) // All Users: Redirect to Tablequery - return case "projectId": - project, _ := api.JobRepository.FindProject(r.Context(), strings.Trim(splitSearch[1], " ")) // Restricted: projectId - if project != "" { - http.Redirect(rw, r, "/monitoring/jobs/?projectMatch=eq&project="+url.QueryEscape(project), http.StatusTemporaryRedirect) - return - } else { - http.Redirect(rw, r, "/monitoring/jobs/?jobId=NotFound", http.StatusTemporaryRedirect) // Workaround to display correctly empty table - } + http.Redirect(rw, r, "/monitoring/jobs/?projectMatch=eq&project="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusTemporaryRedirect) // All Users: Redirect to Tablequery case "username": - usernames, _ := api.JobRepository.FindUsers(r.Context(), strings.Trim(splitSearch[1], " ")) // Restricted: usernames - if len(usernames) == 1 { - http.Redirect(rw, r, "/monitoring/user/"+usernames[0], http.StatusTemporaryRedirect) // One Match: Redirect to User View - return - } else if len(usernames) > 1 { - http.Redirect(rw, r, "/monitoring/users/?user="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusTemporaryRedirect) // > 1 Matches: Redirect to user table - return + if user.HasAnyRole([]auth.Role{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { + http.Redirect(rw, r, "/monitoring/users/?user="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusTemporaryRedirect) } else { - http.Redirect(rw, r, "/monitoring/users/?user=NotFound", http.StatusTemporaryRedirect) // Workaround to display correctly empty table + http.Redirect(rw, r, "/monitoring/jobs/?", http.StatusTemporaryRedirect) // Users: Redirect to Tablequery } case "name": - usernames, _ := api.JobRepository.FindUsersByName(r.Context(), strings.Trim(splitSearch[1], " ")) // Restricted: usernames queried by name - if len(usernames) == 1 { - http.Redirect(rw, r, "/monitoring/user/"+usernames[0], http.StatusTemporaryRedirect) - return - } else if len(usernames) > 1 { + usernames, _ := api.JobRepository.FindColumnValues(user, strings.Trim(splitSearch[1], " "), "user", "username", "name") + if len(usernames) != 0 { joinedNames := strings.Join(usernames, "&user=") - http.Redirect(rw, r, "/monitoring/users/?user="+joinedNames, http.StatusTemporaryRedirect) // > 1 Matches: Redirect to user table - return + http.Redirect(rw, r, "/monitoring/users/?user="+joinedNames, http.StatusTemporaryRedirect) } else { - http.Redirect(rw, r, "/monitoring/users/?user=NotFound", http.StatusTemporaryRedirect) // Workaround to display correctly empty table + if user.HasAnyRole([]auth.Role{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { + http.Redirect(rw, r, "/monitoring/users/?user=NoUserNameFound", http.StatusTemporaryRedirect) + } else { + http.Redirect(rw, r, "/monitoring/jobs/?", http.StatusTemporaryRedirect) // Users: Redirect to Tablequery + } } default: - http.Error(rw, "'searchId' type parameter unknown", http.StatusBadRequest) + log.Warnf("Searchbar type parameter '%s' unknown", strings.Trim(splitSearch[0], " ")) + http.Redirect(rw, r, "/monitoring/jobs/?", http.StatusTemporaryRedirect) // Unknown: Redirect to Tablequery } } else if len(splitSearch) == 1 { - jobname, username, project, err := api.JobRepository.FindJobnameOrUserOrProject(r.Context(), strings.Trim(search, " ")) // Determine Access within + username, project, jobname, err := api.JobRepository.FindUserOrProjectOrJobname(r.Context(), strings.Trim(search, " ")) if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return + log.Errorf("Error while searchbar best guess: %v", err.Error()) + http.Redirect(rw, r, "/monitoring/jobs/?", http.StatusTemporaryRedirect) // Unknown: Redirect to Tablequery } if username != "" { http.Redirect(rw, r, "/monitoring/user/"+username, http.StatusTemporaryRedirect) // User: Redirect to user page - return } else if project != "" { http.Redirect(rw, r, "/monitoring/jobs/?projectMatch=eq&project="+url.QueryEscape(strings.Trim(search, " ")), http.StatusTemporaryRedirect) // projectId (equal) - return } else if jobname != "" { http.Redirect(rw, r, "/monitoring/jobs/?jobName="+url.QueryEscape(strings.Trim(search, " ")), http.StatusTemporaryRedirect) // JobName (contains) - return } else { http.Redirect(rw, r, "/monitoring/jobs/?jobId="+url.QueryEscape(strings.Trim(search, " ")), http.StatusTemporaryRedirect) // No Result: Probably jobId - return } } else { - http.Error(rw, "'searchId' query parameter malformed", http.StatusBadRequest) + log.Warnf("Searchbar query parameters malformed: %v", search) + http.Redirect(rw, r, "/monitoring/jobs/?", http.StatusTemporaryRedirect) // Unknown: Redirect to Tablequery } } else { diff --git a/test/test.db b/test/test.db index cd36915..bf9e2ce 100644 Binary files a/test/test.db and b/test/test.db differ diff --git a/web/frontend/src/Config.root.svelte b/web/frontend/src/Config.root.svelte index 6b1eb40..6df579f 100644 --- a/web/frontend/src/Config.root.svelte +++ b/web/frontend/src/Config.root.svelte @@ -10,11 +10,11 @@ const ccconfig = getContext('cc-config') - export let user + export let isAdmin -{#if user.IsAdmin} +{#if isAdmin == true} Admin Options diff --git a/web/frontend/src/Header.svelte b/web/frontend/src/Header.svelte index af32a23..227a9d4 100644 --- a/web/frontend/src/Header.svelte +++ b/web/frontend/src/Header.svelte @@ -4,23 +4,45 @@ Dropdown, DropdownToggle, DropdownMenu, DropdownItem, InputGroupText } from 'sveltestrap' export let username // empty string if auth. is disabled, otherwise the username as string - export let isAdmin // boolean + export let authlevel // Integer export let clusters // array of names + export let roles // Role Enum-Like let isOpen = false - const views = [ - isAdmin - ? { title: 'Jobs', adminOnly: false, href: '/monitoring/jobs/', icon: 'card-list' } - : { title: 'My Jobs', adminOnly: false, href: `/monitoring/user/${username}`, icon: 'bar-chart-line-fill' }, - { title: 'Users', adminOnly: true, href: '/monitoring/users/', icon: 'people-fill' }, - { title: 'Projects', adminOnly: true, href: '/monitoring/projects/', icon: 'folder' }, - { title: 'Tags', adminOnly: false, href: '/monitoring/tags/', icon: 'tags' } + const userviews = [ + { title: 'My Jobs', href: `/monitoring/user/${username}`, icon: 'bar-chart-line-fill' }, + { title: `Job Search`, href: '/monitoring/jobs/', icon: 'card-list' }, + { title: 'Tags', href: '/monitoring/tags/', icon: 'tags' } ] + + const managerviews = [ + { title: 'My Jobs', href: `/monitoring/user/${username}`, icon: 'bar-chart-line-fill' }, + { title: `Managed Jobs`, href: '/monitoring/jobs/', icon: 'card-list' }, + { title: `Managed Users`, href: '/monitoring/users/', icon: 'people-fill' }, + { title: 'Tags', href: '/monitoring/tags/', icon: 'tags' } + ] + + const supportviews = [ + { title: 'My Jobs', href: `/monitoring/user/${username}`, icon: 'bar-chart-line-fill' }, + { title: 'Jobs', href: '/monitoring/jobs/', icon: 'card-list' }, + { title: 'Users', href: '/monitoring/users/', icon: 'people-fill' }, + { title: 'Projects', href: '/monitoring/projects/', icon: 'folder' }, + { title: 'Tags', href: '/monitoring/tags/', icon: 'tags' } + ] + + const adminviews = [ + { title: 'My Jobs', href: `/monitoring/user/${username}`, icon: 'bar-chart-line-fill' }, + { title: 'Jobs', href: '/monitoring/jobs/', icon: 'card-list' }, + { title: 'Users', href: '/monitoring/users/', icon: 'people-fill' }, + { title: 'Projects', href: '/monitoring/projects/', icon: 'folder' }, + { title: 'Tags', href: '/monitoring/tags/', icon: 'tags' } + ] + const viewsPerCluster = [ - { title: 'Analysis', adminOnly: true, href: '/monitoring/analysis/', icon: 'graph-up' }, - { title: 'Systems', adminOnly: true, href: '/monitoring/systems/', icon: 'cpu' }, - { title: 'Status', adminOnly: true, href: '/monitoring/status/', icon: 'cpu' }, + { title: 'Analysis', requiredRole: roles.support, href: '/monitoring/analysis/', icon: 'graph-up' }, + { title: 'Systems', requiredRole: roles.admin, href: '/monitoring/systems/', icon: 'cpu' }, + { title: 'Status', requiredRole: roles.admin, href: '/monitoring/status/', icon: 'cpu' }, ] @@ -31,10 +53,26 @@ (isOpen = !isOpen)} /> (isOpen = detail.isOpen)}>