diff --git a/cmd/cc-backend/main.go b/cmd/cc-backend/main.go index ad69c58..b0faa13 100644 --- a/cmd/cc-backend/main.go +++ b/cmd/cc-backend/main.go @@ -371,6 +371,10 @@ func main() { }) secured := r.PathPrefix("/").Subrouter() + securedapi := r.PathPrefix("/api").Subrouter() + userapi := r.PathPrefix("/userapi").Subrouter() + configapi := r.PathPrefix("/config").Subrouter() + frontendapi := r.PathPrefix("/frontend").Subrouter() if !config.Keys.DisableAuthentication { r.Handle("/login", authentication.Login( @@ -437,6 +441,70 @@ func main() { }) }) }) + + securedapi.Use(func(next http.Handler) http.Handler { + return authentication.AuthApi( + // On success; + next, + + // On failure: JSON Response + func(rw http.ResponseWriter, r *http.Request, err error) { + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(rw).Encode(map[string]string{ + "status": http.StatusText(http.StatusUnauthorized), + "error": err.Error(), + }) + }) + }) + + userapi.Use(func(next http.Handler) http.Handler { + return authentication.AuthUserApi( + // On success; + next, + + // On failure: JSON Response + func(rw http.ResponseWriter, r *http.Request, err error) { + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(rw).Encode(map[string]string{ + "status": http.StatusText(http.StatusUnauthorized), + "error": err.Error(), + }) + }) + }) + + configapi.Use(func(next http.Handler) http.Handler { + return authentication.AuthConfigApi( + // On success; + next, + + // On failure: JSON Response + func(rw http.ResponseWriter, r *http.Request, err error) { + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(rw).Encode(map[string]string{ + "status": http.StatusText(http.StatusUnauthorized), + "error": err.Error(), + }) + }) + }) + + frontendapi.Use(func(next http.Handler) http.Handler { + return authentication.AuthFrontendApi( + // On success; + next, + + // On failure: JSON Response + func(rw http.ResponseWriter, r *http.Request, err error) { + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(rw).Encode(map[string]string{ + "status": http.StatusText(http.StatusUnauthorized), + "error": err.Error(), + }) + }) + }) } if flagDev { @@ -453,7 +521,10 @@ func main() { // Mount all /monitoring/... and /api/... routes. routerConfig.SetupRoutes(secured, buildInfo) - api.MountRoutes(secured) + api.MountApiRoutes(securedapi) + api.MountUserApiRoutes(userapi) + api.MountConfigApiRoutes(configapi) + api.MountFrontendApiRoutes(frontendapi) if config.Keys.EmbedStaticFiles { if i, err := os.Stat("./var/img"); err == nil { diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 871afc9..0354a0f 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -197,7 +197,9 @@ func TestRestApi(t *testing.T) { } r := mux.NewRouter() - restapi.MountRoutes(r) + r.PathPrefix("/api").Subrouter() + r.StrictSlash(true) + restapi.MountApiRoutes(r) const startJobBody string = `{ "jobId": 123, @@ -225,11 +227,22 @@ func TestRestApi(t *testing.T) { }` var dbid int64 + const contextUserKey repository.ContextKey = "user" + contextUserValue := &schema.User{ + Username: "testuser", + Projects: make([]string, 0), + Roles: []string{"user"}, + AuthType: 0, + AuthSource: 2, + } + if ok := t.Run("StartJob", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/jobs/start_job/", bytes.NewBuffer([]byte(startJobBody))) + req := httptest.NewRequest(http.MethodPost, "/jobs/start_job/", bytes.NewBuffer([]byte(startJobBody))) recorder := httptest.NewRecorder() - r.ServeHTTP(recorder, req) + ctx := context.WithValue(req.Context(), contextUserKey, contextUserValue) + + r.ServeHTTP(recorder, req.WithContext(ctx)) response := recorder.Result() if response.StatusCode != http.StatusCreated { t.Fatal(response.Status, recorder.Body.String()) @@ -240,12 +253,12 @@ func TestRestApi(t *testing.T) { t.Fatal(err) } - job, err := restapi.Resolver.Query().Job(context.Background(), strconv.Itoa(int(res.DBID))) + job, err := restapi.Resolver.Query().Job(ctx, strconv.Itoa(int(res.DBID))) if err != nil { t.Fatal(err) } - job.Tags, err = restapi.Resolver.Job().Tags(context.Background(), job) + job.Tags, err = restapi.Resolver.Job().Tags(ctx, job) if err != nil { t.Fatal(err) } @@ -289,17 +302,19 @@ func TestRestApi(t *testing.T) { var stoppedJob *schema.Job if ok := t.Run("StopJob", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/jobs/stop_job/", bytes.NewBuffer([]byte(stopJobBody))) + req := httptest.NewRequest(http.MethodPost, "/jobs/stop_job/", bytes.NewBuffer([]byte(stopJobBody))) recorder := httptest.NewRecorder() - r.ServeHTTP(recorder, req) + ctx := context.WithValue(req.Context(), contextUserKey, contextUserValue) + + r.ServeHTTP(recorder, req.WithContext(ctx)) response := recorder.Result() if response.StatusCode != http.StatusOK { t.Fatal(response.Status, recorder.Body.String()) } restapi.JobRepository.WaitForArchiving() - job, err := restapi.Resolver.Query().Job(context.Background(), strconv.Itoa(int(dbid))) + job, err := restapi.Resolver.Query().Job(ctx, strconv.Itoa(int(dbid))) if err != nil { t.Fatal(err) } @@ -341,10 +356,12 @@ func TestRestApi(t *testing.T) { // Starting a job with the same jobId and cluster should only be allowed if the startTime is far appart! body := strings.Replace(startJobBody, `"startTime": 123456789`, `"startTime": 123456790`, -1) - req := httptest.NewRequest(http.MethodPost, "/api/jobs/start_job/", bytes.NewBuffer([]byte(body))) + req := httptest.NewRequest(http.MethodPost, "/jobs/start_job/", bytes.NewBuffer([]byte(body))) recorder := httptest.NewRecorder() - r.ServeHTTP(recorder, req) + ctx := context.WithValue(req.Context(), contextUserKey, contextUserValue) + + r.ServeHTTP(recorder, req.WithContext(ctx)) response := recorder.Result() if response.StatusCode != http.StatusUnprocessableEntity { t.Fatal(response.Status, recorder.Body.String()) @@ -371,10 +388,12 @@ func TestRestApi(t *testing.T) { }` ok := t.Run("StartJobFailed", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/jobs/start_job/", bytes.NewBuffer([]byte(startJobBodyFailed))) + req := httptest.NewRequest(http.MethodPost, "/jobs/start_job/", bytes.NewBuffer([]byte(startJobBodyFailed))) recorder := httptest.NewRecorder() - r.ServeHTTP(recorder, req) + ctx := context.WithValue(req.Context(), contextUserKey, contextUserValue) + + r.ServeHTTP(recorder, req.WithContext(ctx)) response := recorder.Result() if response.StatusCode != http.StatusCreated { t.Fatal(response.Status, recorder.Body.String()) @@ -393,10 +412,12 @@ func TestRestApi(t *testing.T) { }` ok = t.Run("StopJobFailed", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/jobs/stop_job/", bytes.NewBuffer([]byte(stopJobBodyFailed))) + req := httptest.NewRequest(http.MethodPost, "/jobs/stop_job/", bytes.NewBuffer([]byte(stopJobBodyFailed))) recorder := httptest.NewRecorder() - r.ServeHTTP(recorder, req) + ctx := context.WithValue(req.Context(), contextUserKey, contextUserValue) + + r.ServeHTTP(recorder, req.WithContext(ctx)) response := recorder.Result() if response.StatusCode != http.StatusOK { t.Fatal(response.Status, recorder.Body.String()) diff --git a/internal/api/rest.go b/internal/api/rest.go index e43cf51..b447a21 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -59,8 +59,7 @@ type RestApi struct { RepositoryMutex sync.Mutex } -func (api *RestApi) MountRoutes(r *mux.Router) { - r = r.PathPrefix("/api").Subrouter() +func (api *RestApi) MountApiRoutes(r *mux.Router) { r.StrictSlash(true) r.HandleFunc("/jobs/start_job/", api.startJob).Methods(http.MethodPost, http.MethodPut) @@ -84,15 +83,36 @@ func (api *RestApi) MountRoutes(r *mux.Router) { r.HandleFunc("/machine_state/{cluster}/{host}", api.getMachineState).Methods(http.MethodGet) r.HandleFunc("/machine_state/{cluster}/{host}", api.putMachineState).Methods(http.MethodPut, http.MethodPost) } +} + +func (api *RestApi) MountUserApiRoutes(r *mux.Router) { + r.StrictSlash(true) + + r.HandleFunc("/jobs/", api.getJobs).Methods(http.MethodGet) + r.HandleFunc("/jobs/{id}", api.getJobById).Methods(http.MethodPost) + r.HandleFunc("/jobs/{id}", api.getCompleteJobById).Methods(http.MethodGet) + r.HandleFunc("/jobs/metrics/{id}", api.getJobMetrics).Methods(http.MethodGet) +} + +func (api *RestApi) MountConfigApiRoutes(r *mux.Router) { + r.StrictSlash(true) 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) r.HandleFunc("/user/{id}", api.updateUser).Methods(http.MethodPost) + } +} + +func (api *RestApi) MountFrontendApiRoutes(r *mux.Router) { + r.StrictSlash(true) + + if api.Authentication != nil { + r.HandleFunc("/jwt/", api.getJWT).Methods(http.MethodGet) r.HandleFunc("/configuration/", api.updateConfiguration).Methods(http.MethodPost) + r.HandleFunc("/jobs/metrics/{id}", api.getJobMetrics).Methods(http.MethodGet) // Fetched in Job.svelte: Needs All-User-Access-Session-Auth } } @@ -311,13 +331,6 @@ func (api *RestApi) getClusters(rw http.ResponseWriter, r *http.Request) { // @security ApiKeyAuth // @router /jobs/ [get] func (api *RestApi) getJobs(rw http.ResponseWriter, r *http.Request) { - if user := repository.GetUserFromContext(r.Context()); user != nil && - !user.HasRole(schema.RoleApi) { - - handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw) - return - } - withMetadata := false filter := &model.JobFilter{} page := &model.PageRequest{ItemsPerPage: 25, Page: 1} @@ -434,7 +447,7 @@ func (api *RestApi) getJobs(rw http.ResponseWriter, r *http.Request) { } } -// getJobById godoc +// getCompleteJobById godoc // @summary Get job meta and optional all metric data // @tags Job query // @description Job to get is specified by database ID @@ -452,14 +465,6 @@ func (api *RestApi) getJobs(rw http.ResponseWriter, r *http.Request) { // @security ApiKeyAuth // @router /jobs/{id} [get] func (api *RestApi) getCompleteJobById(rw http.ResponseWriter, r *http.Request) { - if user := repository.GetUserFromContext(r.Context()); user != nil && - !user.HasRole(schema.RoleApi) { - - handleError(fmt.Errorf("missing role: %v", - schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw) - return - } - // Fetch job from db id, ok := mux.Vars(r)["id"] var job *schema.Job @@ -471,7 +476,7 @@ func (api *RestApi) getCompleteJobById(rw http.ResponseWriter, r *http.Request) return } - job, err = api.JobRepository.FindById(id) + job, err = api.JobRepository.FindById(r.Context(), id) // Get Job from Repo by ID } else { handleError(errors.New("the parameter 'id' is required"), http.StatusBadRequest, rw) return @@ -546,14 +551,6 @@ func (api *RestApi) getCompleteJobById(rw http.ResponseWriter, r *http.Request) // @security ApiKeyAuth // @router /jobs/{id} [post] func (api *RestApi) getJobById(rw http.ResponseWriter, r *http.Request) { - if user := repository.GetUserFromContext(r.Context()); user != nil && - !user.HasRole(schema.RoleApi) { - - handleError(fmt.Errorf("missing role: %v", - schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw) - return - } - // Fetch job from db id, ok := mux.Vars(r)["id"] var job *schema.Job @@ -565,7 +562,7 @@ func (api *RestApi) getJobById(rw http.ResponseWriter, r *http.Request) { return } - job, err = api.JobRepository.FindById(id) + job, err = api.JobRepository.FindById(r.Context(), id) } else { handleError(errors.New("the parameter 'id' is required"), http.StatusBadRequest, rw) return @@ -651,19 +648,13 @@ func (api *RestApi) getJobById(rw http.ResponseWriter, r *http.Request) { // @security ApiKeyAuth // @router /jobs/edit_meta/{id} [post] func (api *RestApi) editMeta(rw http.ResponseWriter, r *http.Request) { - if user := repository.GetUserFromContext(r.Context()); user != nil && - !user.HasRole(schema.RoleApi) { - handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw) - return - } - - iid, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64) + id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64) if err != nil { http.Error(rw, err.Error(), http.StatusBadRequest) return } - job, err := api.JobRepository.FindById(iid) + job, err := api.JobRepository.FindById(r.Context(), id) if err != nil { http.Error(rw, err.Error(), http.StatusNotFound) return @@ -702,20 +693,13 @@ func (api *RestApi) editMeta(rw http.ResponseWriter, r *http.Request) { // @security ApiKeyAuth // @router /jobs/tag_job/{id} [post] func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) { - if user := repository.GetUserFromContext(r.Context()); user != nil && - !user.HasRole(schema.RoleApi) { - - handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw) - return - } - - iid, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64) + id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64) if err != nil { http.Error(rw, err.Error(), http.StatusBadRequest) return } - job, err := api.JobRepository.FindById(iid) + job, err := api.JobRepository.FindById(r.Context(), id) if err != nil { http.Error(rw, err.Error(), http.StatusNotFound) return @@ -769,13 +753,6 @@ func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) { // @security ApiKeyAuth // @router /jobs/start_job/ [post] func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) { - if user := repository.GetUserFromContext(r.Context()); user != nil && - !user.HasRole(schema.RoleApi) { - - handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw) - return - } - req := schema.JobMeta{BaseJob: schema.JobDefaults} if err := decode(r.Body, &req); err != nil { handleError(fmt.Errorf("parsing request body failed: %w", err), http.StatusBadRequest, rw) @@ -852,13 +829,6 @@ func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) { // @security ApiKeyAuth // @router /jobs/stop_job/{id} [post] func (api *RestApi) stopJobById(rw http.ResponseWriter, r *http.Request) { - if user := repository.GetUserFromContext(r.Context()); user != nil && - !user.HasRole(schema.RoleApi) { - - handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw) - return - } - // Parse request body: Only StopTime and State req := StopJobApiRequest{} if err := decode(r.Body, &req); err != nil { @@ -877,7 +847,7 @@ func (api *RestApi) stopJobById(rw http.ResponseWriter, r *http.Request) { return } - job, err = api.JobRepository.FindById(id) + job, err = api.JobRepository.FindById(r.Context(), id) } else { handleError(errors.New("the parameter 'id' is required"), http.StatusBadRequest, rw) return @@ -907,13 +877,6 @@ func (api *RestApi) stopJobById(rw http.ResponseWriter, r *http.Request) { // @security ApiKeyAuth // @router /jobs/stop_job/ [post] func (api *RestApi) stopJobByRequest(rw http.ResponseWriter, r *http.Request) { - if user := repository.GetUserFromContext(r.Context()); user != nil && - !user.HasRole(schema.RoleApi) { - - handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw) - return - } - // Parse request body req := StopJobApiRequest{} if err := decode(r.Body, &req); err != nil { @@ -955,11 +918,6 @@ func (api *RestApi) stopJobByRequest(rw http.ResponseWriter, r *http.Request) { // @security ApiKeyAuth // @router /jobs/delete_job/{id} [delete] func (api *RestApi) deleteJobById(rw http.ResponseWriter, r *http.Request) { - if user := repository.GetUserFromContext(r.Context()); user != nil && !user.HasRole(schema.RoleApi) { - handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw) - return - } - // Fetch job (that will be stopped) from db id, ok := mux.Vars(r)["id"] var err error @@ -1003,12 +961,6 @@ func (api *RestApi) deleteJobById(rw http.ResponseWriter, r *http.Request) { // @security ApiKeyAuth // @router /jobs/delete_job/ [delete] func (api *RestApi) deleteJobByRequest(rw http.ResponseWriter, r *http.Request) { - if user := repository.GetUserFromContext(r.Context()); user != nil && - !user.HasRole(schema.RoleApi) { - handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw) - return - } - // Parse request body req := DeleteJobApiRequest{} if err := decode(r.Body, &req); err != nil { @@ -1060,11 +1012,6 @@ func (api *RestApi) deleteJobByRequest(rw http.ResponseWriter, r *http.Request) // @security ApiKeyAuth // @router /jobs/delete_job_before/{ts} [delete] func (api *RestApi) deleteJobBefore(rw http.ResponseWriter, r *http.Request) { - if user := repository.GetUserFromContext(r.Context()); user != nil && !user.HasRole(schema.RoleApi) { - handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw) - return - } - var cnt int // Fetch job (that will be stopped) from db id, ok := mux.Vars(r)["ts"] diff --git a/internal/auth/auth.go b/internal/auth/auth.go index bedd9c7..50f4121 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -219,31 +219,141 @@ func (auth *Authentication) Auth( return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { user, err := auth.JwtAuth.AuthViaJWT(rw, r) if err != nil { - log.Infof("authentication failed: %s", err.Error()) + log.Infof("auth -> authentication failed: %s", err.Error()) http.Error(rw, err.Error(), http.StatusUnauthorized) return } - if user == nil { user, err = auth.AuthViaSession(rw, r) if err != nil { - log.Infof("authentication failed: %s", err.Error()) + log.Infof("auth -> authentication failed: %s", err.Error()) http.Error(rw, err.Error(), http.StatusUnauthorized) return } } - if user != nil { ctx := context.WithValue(r.Context(), repository.ContextUserKey, user) onsuccess.ServeHTTP(rw, r.WithContext(ctx)) return } - log.Debug("authentication failed") + log.Info("auth -> authentication failed") onfailure(rw, r, errors.New("unauthorized (please login first)")) }) } +func (auth *Authentication) AuthApi( + onsuccess http.Handler, + onfailure func(rw http.ResponseWriter, r *http.Request, authErr error), +) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + user, err := auth.JwtAuth.AuthViaJWT(rw, r) + if err != nil { + log.Infof("auth api -> authentication failed: %s", err.Error()) + onfailure(rw, r, err) + return + } + if user != nil { + switch { + case len(user.Roles) == 1: + if user.HasRole(schema.RoleApi) { + ctx := context.WithValue(r.Context(), repository.ContextUserKey, user) + onsuccess.ServeHTTP(rw, r.WithContext(ctx)) + return + } + case len(user.Roles) >= 2: + if user.HasAllRoles([]schema.Role{schema.RoleAdmin, schema.RoleApi}) { + ctx := context.WithValue(r.Context(), repository.ContextUserKey, user) + onsuccess.ServeHTTP(rw, r.WithContext(ctx)) + return + } + default: + log.Info("auth api -> authentication failed: missing role") + onfailure(rw, r, errors.New("unauthorized")) + } + } + log.Info("auth api -> authentication failed: no auth") + onfailure(rw, r, errors.New("unauthorized")) + }) +} + +func (auth *Authentication) AuthUserApi( + onsuccess http.Handler, + onfailure func(rw http.ResponseWriter, r *http.Request, authErr error), +) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + user, err := auth.JwtAuth.AuthViaJWT(rw, r) + if err != nil { + log.Infof("auth user api -> authentication failed: %s", err.Error()) + onfailure(rw, r, err) + return + } + if user != nil { + switch { + case len(user.Roles) == 1: + if user.HasRole(schema.RoleApi) { + ctx := context.WithValue(r.Context(), repository.ContextUserKey, user) + onsuccess.ServeHTTP(rw, r.WithContext(ctx)) + return + } + case len(user.Roles) >= 2: + if user.HasRole(schema.RoleApi) && user.HasAnyRole([]schema.Role{schema.RoleUser, schema.RoleManager, schema.RoleAdmin}) { + ctx := context.WithValue(r.Context(), repository.ContextUserKey, user) + onsuccess.ServeHTTP(rw, r.WithContext(ctx)) + return + } + default: + log.Info("auth user api -> authentication failed: missing role") + onfailure(rw, r, errors.New("unauthorized")) + } + } + log.Info("auth user api -> authentication failed: no auth") + onfailure(rw, r, errors.New("unauthorized")) + }) +} + +func (auth *Authentication) AuthConfigApi( + onsuccess http.Handler, + onfailure func(rw http.ResponseWriter, r *http.Request, authErr error), +) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + user, err := auth.AuthViaSession(rw, r) + if err != nil { + log.Infof("auth config api -> authentication failed: %s", err.Error()) + onfailure(rw, r, err) + return + } + if user != nil && user.HasRole(schema.RoleAdmin) { + ctx := context.WithValue(r.Context(), repository.ContextUserKey, user) + onsuccess.ServeHTTP(rw, r.WithContext(ctx)) + return + } + log.Info("auth config api -> authentication failed: no auth") + onfailure(rw, r, errors.New("unauthorized")) + }) +} + +func (auth *Authentication) AuthFrontendApi( + onsuccess http.Handler, + onfailure func(rw http.ResponseWriter, r *http.Request, authErr error), +) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + user, err := auth.AuthViaSession(rw, r) + if err != nil { + log.Infof("auth frontend api -> authentication failed: %s", err.Error()) + onfailure(rw, r, err) + return + } + if user != nil { + ctx := context.WithValue(r.Context(), repository.ContextUserKey, user) + onsuccess.ServeHTTP(rw, r.WithContext(ctx)) + return + } + log.Info("auth frontend api -> authentication failed: no auth") + onfailure(rw, r, errors.New("unauthorized")) + }) +} + func (auth *Authentication) Logout(onsuccess http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { session, err := auth.sessionStore.Get(r, "session") diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index 21d5194..c621e4d 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -182,7 +182,7 @@ func (r *queryResolver) Job(ctx context.Context, id string) (*schema.Job, error) return nil, err } - job, err := r.Repo.FindById(numericId) + job, err := r.Repo.FindById(ctx, numericId) if err != nil { log.Warn("Error while finding job by id") return nil, err diff --git a/internal/repository/jobFind.go b/internal/repository/jobFind.go index f1264dd..842d5f4 100644 --- a/internal/repository/jobFind.go +++ b/internal/repository/jobFind.go @@ -86,12 +86,63 @@ func (r *JobRepository) FindAll( // The job is queried using the database id. // It returns a pointer to a schema.Job data structure and an error variable. // To check if no job was found test err == sql.ErrNoRows -func (r *JobRepository) FindById(jobId int64) (*schema.Job, error) { +func (r *JobRepository) FindById(ctx context.Context, jobId int64) (*schema.Job, error) { + q := sq.Select(jobColumns...). + From("job").Where("job.id = ?", jobId) + + q, qerr := SecurityCheck(ctx, q) + if qerr != nil { + return nil, qerr + } + + return scanJob(q.RunWith(r.stmtCache).QueryRow()) +} + +// FindByIdDirect executes a SQL query to find a specific batch job. +// The job is queried using the database id. +// It returns a pointer to a schema.Job data structure and an error variable. +// To check if no job was found test err == sql.ErrNoRows +func (r *JobRepository) FindByIdDirect(jobId int64) (*schema.Job, error) { q := sq.Select(jobColumns...). From("job").Where("job.id = ?", jobId) return scanJob(q.RunWith(r.stmtCache).QueryRow()) } +// FindByJobId executes a SQL query to find a specific batch job. +// The job is queried using the slurm id and the clustername. +// It returns a pointer to a schema.Job data structure and an error variable. +// To check if no job was found test err == sql.ErrNoRows +func (r *JobRepository) FindByJobId(ctx context.Context, jobId int64, startTime int64, cluster string) (*schema.Job, error) { + q := sq.Select(jobColumns...). + From("job"). + Where("job.job_id = ?", jobId). + Where("job.cluster = ?", cluster). + Where("job.start_time = ?", startTime) + + q, qerr := SecurityCheck(ctx, q) + if qerr != nil { + return nil, qerr + } + + return scanJob(q.RunWith(r.stmtCache).QueryRow()) +} + +// IsJobOwner executes a SQL query to find a specific batch job. +// The job is queried using the slurm id,a username and the cluster. +// It returns a bool. +// If job was found, user is owner: test err != sql.ErrNoRows +func (r *JobRepository) IsJobOwner(jobId int64, startTime int64, user string, cluster string) bool { + q := sq.Select("id"). + From("job"). + Where("job.job_id = ?", jobId). + Where("job.user = ?", user). + Where("job.cluster = ?", cluster). + Where("job.start_time = ?", startTime) + + _, err := scanJob(q.RunWith(r.stmtCache).QueryRow()) + return err != sql.ErrNoRows +} + func (r *JobRepository) FindConcurrentJobs( ctx context.Context, job *schema.Job, diff --git a/internal/repository/jobQuery.go b/internal/repository/jobQuery.go index 394856d..a985519 100644 --- a/internal/repository/jobQuery.go +++ b/internal/repository/jobQuery.go @@ -97,23 +97,25 @@ func SecurityCheck(ctx context.Context, query sq.SelectBuilder) (sq.SelectBuilde if user == nil { var qnil sq.SelectBuilder return qnil, fmt.Errorf("user context is nil") - } else if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleApi}) { // Admin & Co. : All jobs + } + + switch { + case len(user.Roles) == 1 && user.HasRole(schema.RoleApi): // API-User : All jobs return query, nil - } else if user.HasRole(schema.RoleManager) { // Manager : Add filter for managed projects' jobs only + personal jobs + case user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}): // Admin & Support : All jobs + return query, nil + case user.HasRole(schema.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.Debugf("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(schema.RoleUser) { // User : Only personal jobs + case user.HasRole(schema.RoleUser): // User : Only personal jobs return query.Where("job.user = ?", user.Username), nil - } else { - // Shortterm compatibility: Return User-Query if no roles: - return query.Where("job.user = ?", user.Username), nil - // // On the longterm: Return Error instead of fallback: - // var qnil sq.SelectBuilder - // return qnil, fmt.Errorf("user '%s' with unknown roles [%#v]", user.Username, user.Roles) + default: // No known Role, return error + var qnil sq.SelectBuilder + return qnil, fmt.Errorf("user has no or unknown roles") } } diff --git a/internal/repository/job_test.go b/internal/repository/job_test.go index e6a0b09..2589fb9 100644 --- a/internal/repository/job_test.go +++ b/internal/repository/job_test.go @@ -30,7 +30,7 @@ func TestFind(t *testing.T) { func TestFindById(t *testing.T) { r := setup(t) - job, err := r.FindById(5) + job, err := r.FindById(getContext(t), 5) if err != nil { t.Fatal(err) } diff --git a/internal/repository/repository_test.go b/internal/repository/repository_test.go index 16d94d2..6d1fbfc 100644 --- a/internal/repository/repository_test.go +++ b/internal/repository/repository_test.go @@ -55,7 +55,7 @@ func BenchmarkDB_FindJobById(b *testing.B) { b.RunParallel(func(pb *testing.PB) { for pb.Next() { - _, err := db.FindById(jobId) + _, err := db.FindById(getContext(b), jobId) noErr(b, err) } }) diff --git a/internal/repository/tags.go b/internal/repository/tags.go index e0c14f3..8dace03 100644 --- a/internal/repository/tags.go +++ b/internal/repository/tags.go @@ -23,7 +23,7 @@ func (r *JobRepository) AddTag(job int64, tag int64) ([]*schema.Tag, error) { return nil, err } - j, err := r.FindById(job) + j, err := r.FindByIdDirect(job) if err != nil { log.Warn("Error while finding job by id") return nil, err @@ -48,7 +48,7 @@ func (r *JobRepository) RemoveTag(job, tag int64) ([]*schema.Tag, error) { return nil, err } - j, err := r.FindById(job) + j, err := r.FindByIdDirect(job) if err != nil { log.Warn("Error while finding job by id") return nil, err diff --git a/web/frontend/src/Config.root.svelte b/web/frontend/src/Config.root.svelte index ddd714f..61e99a8 100644 --- a/web/frontend/src/Config.root.svelte +++ b/web/frontend/src/Config.root.svelte @@ -1,16 +1,15 @@ {#if isAdmin == true} @@ -24,7 +23,7 @@ - Plotting Options + User Options - + diff --git a/web/frontend/src/config.entrypoint.js b/web/frontend/src/config.entrypoint.js index 276f648..2978c8c 100644 --- a/web/frontend/src/config.entrypoint.js +++ b/web/frontend/src/config.entrypoint.js @@ -4,7 +4,9 @@ import Config from './Config.root.svelte' new Config({ target: document.getElementById('svelte-app'), props: { - isAdmin: isAdmin + isAdmin: isAdmin, + isApi: isApi, + username: username }, context: new Map([ ['cc-config', clusterCockpitConfig] diff --git a/web/frontend/src/config/AdminSettings.svelte b/web/frontend/src/config/AdminSettings.svelte index 26e1d0f..56ecb76 100644 --- a/web/frontend/src/config/AdminSettings.svelte +++ b/web/frontend/src/config/AdminSettings.svelte @@ -11,7 +11,7 @@ let roles = []; function getUserList() { - fetch("/api/users/?via-ldap=false¬-just-user=true") + fetch("/config/users/?via-ldap=false¬-just-user=true") .then((res) => res.json()) .then((usersRaw) => { users = usersRaw; @@ -19,7 +19,7 @@ } function getValidRoles() { - fetch("/api/roles/") + fetch("/config/roles/") .then((res) => res.json()) .then((rolesRaw) => { roles = rolesRaw; diff --git a/web/frontend/src/config/PlotSettings.svelte b/web/frontend/src/config/PlotSettings.svelte deleted file mode 100644 index 09610f1..0000000 --- a/web/frontend/src/config/PlotSettings.svelte +++ /dev/null @@ -1,552 +0,0 @@ - - - - - - - - handleSettingSubmit("#line-width-form", "lw")} - > - - - Line Width - - {#if displayMessage && message.target == "lw"} - - - Update: {message.msg} - - - {/if} - - - - Line Width - - - Width of the lines in the timeseries plots. - - - Submit - - - - - - - handleSettingSubmit("#plots-per-row-form", "ppr")} - > - - - Plots per Row - {#if displayMessage && message.target == "ppr"} - Update: {message.msg} - {/if} - - - - Plots per Row - - - How many plots to show next to each other on pages such as - /monitoring/job/, /monitoring/system/... - - - Submit - - - - - - - handleSettingSubmit("#backgrounds-form", "bg")} - > - - - Colored Backgrounds - {#if displayMessage && message.target == "bg"} - Update: {message.msg} - {/if} - - - - - {#if config.plot_general_colorBackground} - - {:else} - - {/if} - Yes - - - {#if config.plot_general_colorBackground} - - {:else} - - {/if} - No - - - Submit - - - - - - - handleSettingSubmit("#paging-form", "pag")} - > - - - Paging Type - {#if displayMessage && message.target == "pag"} - Update: {message.msg} - {/if} - - - - - {#if config.job_list_usePaging} - - {:else} - - {/if} - Paging with selectable count of jobs. - - - {#if config.job_list_usePaging} - - {:else} - - {/if} - Continuous scroll iteratively adding 10 jobs. - - - Submit - - - - - - - - - - - Color Scheme for Timeseries Plots - {#if displayMessage && message.target == "cs"} - Update: {message.msg} - {/if} - - - - - {#each Object.entries(colorschemes) as [name, rgbrow]} - - {name} - - {#if rgbrow.join(",") == config.plot_general_colorscheme} - - handleSettingSubmit("#colorscheme-form", "cs")} - /> - {:else} - - handleSettingSubmit("#colorscheme-form", "cs")} - /> - {/if} - - - {#each rgbrow as rgb} - - {/each} - - - {/each} - - - - - - - diff --git a/web/frontend/src/config/UserSettings.svelte b/web/frontend/src/config/UserSettings.svelte new file mode 100644 index 0000000..cd1d9a3 --- /dev/null +++ b/web/frontend/src/config/UserSettings.svelte @@ -0,0 +1,47 @@ + + + handleSettingSubmit(e)}/> + handleSettingSubmit(e)}/> + handleSettingSubmit(e)}/> diff --git a/web/frontend/src/config/admin/AddUser.svelte b/web/frontend/src/config/admin/AddUser.svelte index 43f08de..84aacc3 100644 --- a/web/frontend/src/config/admin/AddUser.svelte +++ b/web/frontend/src/config/admin/AddUser.svelte @@ -48,7 +48,7 @@ diff --git a/web/frontend/src/config/admin/EditProject.svelte b/web/frontend/src/config/admin/EditProject.svelte index a4a8d75..e1c518f 100644 --- a/web/frontend/src/config/admin/EditProject.svelte +++ b/web/frontend/src/config/admin/EditProject.svelte @@ -22,7 +22,7 @@ formData.append("add-project", project); try { - const res = await fetch(`/api/user/${username}`, { + const res = await fetch(`/config/user/${username}`, { method: "POST", body: formData, }); @@ -54,7 +54,7 @@ formData.append("remove-project", project); try { - const res = await fetch(`/api/user/${username}`, { + const res = await fetch(`/config/user/${username}`, { method: "POST", body: formData, }); diff --git a/web/frontend/src/config/admin/EditRole.svelte b/web/frontend/src/config/admin/EditRole.svelte index f201f38..6b24e3e 100644 --- a/web/frontend/src/config/admin/EditRole.svelte +++ b/web/frontend/src/config/admin/EditRole.svelte @@ -24,7 +24,7 @@ formData.append("add-role", role); try { - const res = await fetch(`/api/user/${username}`, { + const res = await fetch(`/config/user/${username}`, { method: "POST", body: formData, }); @@ -56,7 +56,7 @@ formData.append("remove-role", role); try { - const res = await fetch(`/api/user/${username}`, { + const res = await fetch(`/config/user/${username}`, { method: "POST", body: formData, }); diff --git a/web/frontend/src/config/admin/ShowUsers.svelte b/web/frontend/src/config/admin/ShowUsers.svelte index be9b146..889c5a6 100644 --- a/web/frontend/src/config/admin/ShowUsers.svelte +++ b/web/frontend/src/config/admin/ShowUsers.svelte @@ -20,7 +20,7 @@ if (confirm("Are you sure?")) { let formData = new FormData(); formData.append("username", username); - fetch("/api/users/", { method: "DELETE", body: formData }).then((res) => { + fetch("/config/users/", { method: "DELETE", body: formData }).then((res) => { if (res.status == 200) { reloadUserList(); } else { diff --git a/web/frontend/src/config/admin/ShowUsersRow.svelte b/web/frontend/src/config/admin/ShowUsersRow.svelte index c79e292..9ad9666 100644 --- a/web/frontend/src/config/admin/ShowUsersRow.svelte +++ b/web/frontend/src/config/admin/ShowUsersRow.svelte @@ -1,18 +1,17 @@ diff --git a/web/frontend/src/config/user/PlotColorScheme.svelte b/web/frontend/src/config/user/PlotColorScheme.svelte new file mode 100644 index 0000000..a36f47c --- /dev/null +++ b/web/frontend/src/config/user/PlotColorScheme.svelte @@ -0,0 +1,329 @@ + + + + + + + + + Color Scheme for Timeseries Plots + {#if displayMessage && message.target == "cs"} + Update: {message.msg} + {/if} + + + + + {#each Object.entries(colorschemes) as [name, rgbrow]} + + {name} + + {#if rgbrow.join(",") == config.plot_general_colorscheme} + + updateSetting("#colorscheme-form", "cs")} + /> + {:else} + + updateSetting("#colorscheme-form", "cs")} + /> + {/if} + + + {#each rgbrow as rgb} + + {/each} + + + {/each} + + + + + + + \ No newline at end of file diff --git a/web/frontend/src/config/user/PlotRenderOptions.svelte b/web/frontend/src/config/user/PlotRenderOptions.svelte new file mode 100644 index 0000000..a237ed3 --- /dev/null +++ b/web/frontend/src/config/user/PlotRenderOptions.svelte @@ -0,0 +1,166 @@ + + + + + + + + updateSetting("#line-width-form", "lw")} + > + + + Line Width + + {#if displayMessage && message.target == "lw"} + + + Update: {message.msg} + + + {/if} + + + + Line Width + + + Width of the lines in the timeseries plots. + + + Submit + + + + + + + updateSetting("#plots-per-row-form", "ppr")} + > + + + Plots per Row + {#if displayMessage && message.target == "ppr"} + Update: {message.msg} + {/if} + + + + Plots per Row + + + How many plots to show next to each other on pages such as + /monitoring/job/, /monitoring/system/... + + + Submit + + + + + + + updateSetting("#backgrounds-form", "bg")} + > + + + Colored Backgrounds + {#if displayMessage && message.target == "bg"} + Update: {message.msg} + {/if} + + + + + {#if config.plot_general_colorBackground} + + {:else} + + {/if} + Yes + + + {#if config.plot_general_colorBackground} + + {:else} + + {/if} + No + + + Submit + + + \ No newline at end of file diff --git a/web/frontend/src/config/user/UserOptions.svelte b/web/frontend/src/config/user/UserOptions.svelte new file mode 100644 index 0000000..d589414 --- /dev/null +++ b/web/frontend/src/config/user/UserOptions.svelte @@ -0,0 +1,131 @@ + + + + + + + + updateSetting("#paging-form", "pag")} + > + + + Paging Type + {#if displayMessage && message.target == "pag"} + Update: {message.msg} + {/if} + + + + + {#if config.job_list_usePaging} + + {:else} + + {/if} + Paging with selectable count of jobs. + + + {#if config.job_list_usePaging} + + {:else} + + {/if} + Continuous scroll iteratively adding 10 jobs. + + + Submit + + + + + {#if isApi} + + + + + Generate JWT + {#if jwt} + + Copy JWT to Clipboard + + + Your token is displayed on the right. Press this button to copy it to the clipboard. + + {:else} + + Generate JWT for '{username}' + + + Generate a JSON Web Token for use with the ClusterCockpit REST-API endpoints. + + {/if} + + + + + + + + + Display JWT + {jwt ? jwt : 'Press "Gen. JWT" to request token ...'} + + + + {/if} + \ No newline at end of file diff --git a/web/frontend/src/utils.js b/web/frontend/src/utils.js index 5346208..bb43094 100644 --- a/web/frontend/src/utils.js +++ b/web/frontend/src/utils.js @@ -239,7 +239,7 @@ export async function fetchMetrics(job, metrics, scopes) { try { let res = await fetch( - `/api/jobs/metrics/${job.id}${query.length > 0 ? "?" : ""}${query.join( + `/frontend/jobs/metrics/${job.id}${query.length > 0 ? "?" : ""}${query.join( "&" )}` ); @@ -433,6 +433,18 @@ export function transformPerNodeDataForRoofline(nodes) { return data } +export async function fetchJwt(username) { + const raw = await fetch(`/frontend/jwt/?username=${username}`); + + if (!raw.ok) { + const message = `An error has occured: ${response.status}`; + throw new Error(message); + } + + const res = await raw.text(); + return res; +} + // https://stackoverflow.com/questions/45309447/calculating-median-javascript // function median(numbers) { // const sorted = Array.from(numbers).sort((a, b) => a - b); diff --git a/web/templates/config.tmpl b/web/templates/config.tmpl index f72cd93..9f3f3be 100644 --- a/web/templates/config.tmpl +++ b/web/templates/config.tmpl @@ -8,6 +8,8 @@ {{define "javascript"}}
- Update: {message.msg} -
Update: {message.msg}
+ Update: {message.msg} +
+ Your token is displayed on the right. Press this button to copy it to the clipboard. +
+ Generate a JSON Web Token for use with the ClusterCockpit REST-API endpoints. +