From 61eebc9fbdfaa0f656ac65ddf824eefe91499593 Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Wed, 3 Jul 2024 17:24:26 +0200 Subject: [PATCH 1/8] Rework initial commit - moved frontend configuration api to new subrouter for compatibility --- cmd/cc-backend/main.go | 42 ++++++- internal/api/api_test.go | 2 +- internal/api/rest.go | 110 +++++------------- internal/auth/auth.go | 70 +++++++++++ internal/graph/schema.resolvers.go | 2 +- internal/repository/job.go | 33 +++++- internal/repository/job_test.go | 2 +- internal/repository/query.go | 20 ++-- internal/repository/repository_test.go | 2 +- internal/repository/tags.go | 4 +- web/frontend/src/config/AdminSettings.svelte | 4 +- web/frontend/src/config/PlotSettings.svelte | 10 +- web/frontend/src/config/admin/AddUser.svelte | 2 +- .../src/config/admin/EditProject.svelte | 4 +- web/frontend/src/config/admin/EditRole.svelte | 4 +- .../src/config/admin/ShowUsers.svelte | 2 +- .../src/config/admin/ShowUsersRow.svelte | 2 +- 17 files changed, 201 insertions(+), 114 deletions(-) diff --git a/cmd/cc-backend/main.go b/cmd/cc-backend/main.go index ad69c58..0ac66c1 100644 --- a/cmd/cc-backend/main.go +++ b/cmd/cc-backend/main.go @@ -371,6 +371,8 @@ func main() { }) secured := r.PathPrefix("/").Subrouter() + securedapi := r.PathPrefix("/api").Subrouter() + userapi := r.PathPrefix("/userapi").Subrouter() if !config.Keys.DisableAuthentication { r.Handle("/login", authentication.Login( @@ -437,6 +439,42 @@ func main() { }) }) }) + + securedapi.Use(func(next http.Handler) http.Handler { + return authentication.AuthApi( + // On success; + next, + + // On failure: + func(rw http.ResponseWriter, r *http.Request, err error) { + rw.WriteHeader(http.StatusUnauthorized) + web.RenderTemplate(rw, "login.tmpl", &web.Page{ + Title: "Authentication failed - ClusterCockpit", + MsgType: "alert-danger", + Message: err.Error(), + Build: buildInfo, + Infos: info, + }) + }) + }) + + userapi.Use(func(next http.Handler) http.Handler { + return authentication.AuthUserApi( + // On success; + next, + + // On failure: + func(rw http.ResponseWriter, r *http.Request, err error) { + rw.WriteHeader(http.StatusUnauthorized) + web.RenderTemplate(rw, "login.tmpl", &web.Page{ + Title: "Authentication failed - ClusterCockpit", + MsgType: "alert-danger", + Message: err.Error(), + Build: buildInfo, + Infos: info, + }) + }) + }) } if flagDev { @@ -453,7 +491,9 @@ func main() { // Mount all /monitoring/... and /api/... routes. routerConfig.SetupRoutes(secured, buildInfo) - api.MountRoutes(secured) + api.MountConfigApiRoutes(secured) + api.MountApiRoutes(securedapi) + api.MountUserApiRoutes(userapi) 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..e91357e 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -197,7 +197,7 @@ func TestRestApi(t *testing.T) { } r := mux.NewRouter() - restapi.MountRoutes(r) + restapi.MountApiRoutes(r) const startJobBody string = `{ "jobId": 123, diff --git a/internal/api/rest.go b/internal/api/rest.go index e43cf51..cd4a497 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,6 +83,24 @@ 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) + + if api.Authentication != nil { + r.HandleFunc("/jwt/", api.getJWT).Methods(http.MethodGet) + } +} + +func (api *RestApi) MountConfigApiRoutes(r *mux.Router) { + r = r.PathPrefix("/config").Subrouter() + r.StrictSlash(true) if api.Authentication != nil { r.HandleFunc("/jwt/", api.getJWT).Methods(http.MethodGet) @@ -311,13 +328,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 +444,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 +462,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 +473,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 +548,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 +559,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 +645,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 +690,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 +750,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 +826,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 +844,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 +874,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 +915,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 +958,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 +1009,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..9b42072 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -244,6 +244,76 @@ func (auth *Authentication) Auth( }) } +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("authentication failed: %s", err.Error()) + http.Error(rw, err.Error(), http.StatusUnauthorized) + 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.Debug("authentication failed") + onfailure(rw, r, errors.New("unauthorized (missing role)")) + } + } + log.Debug("authentication failed") + onfailure(rw, r, errors.New("unauthorized (no auth)")) + }) +} + +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("authentication failed: %s", err.Error()) + http.Error(rw, err.Error(), http.StatusUnauthorized) + 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.Debug("authentication failed") + onfailure(rw, r, errors.New("unauthorized (missing role)")) + } + } + log.Debug("authentication failed") + onfailure(rw, r, errors.New("unauthorized (no auth)")) + }) +} + 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 5f55139..c776dad 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -172,7 +172,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/job.go b/internal/repository/job.go index b42598d..ddd93d3 100644 --- a/internal/repository/job.go +++ b/internal/repository/job.go @@ -283,12 +283,43 @@ 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()) } +// 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, user string, cluster string) bool { + q := sq.Select("id"). + From("job"). + Where("job.job_id = ?", jobId). + Where("job.user = ?", user). + Where("job.cluster = ?", cluster) + + _, 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/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/query.go b/internal/repository/query.go index 5ca98fb..21e977f 100644 --- a/internal/repository/query.go +++ b/internal/repository/query.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/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/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 index 09610f1..93018a3 100644 --- a/web/frontend/src/config/PlotSettings.svelte +++ b/web/frontend/src/config/PlotSettings.svelte @@ -283,7 +283,7 @@
handleSettingSubmit("#line-width-form", "lw")} @@ -329,7 +329,7 @@ handleSettingSubmit("#plots-per-row-form", "ppr")} @@ -375,7 +375,7 @@ handleSettingSubmit("#backgrounds-form", "bg")} @@ -429,7 +429,7 @@ handleSettingSubmit("#paging-form", "pag")} @@ -485,7 +485,7 @@ 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..4dae0c9 100644 --- a/web/frontend/src/config/admin/ShowUsersRow.svelte +++ b/web/frontend/src/config/admin/ShowUsersRow.svelte @@ -5,7 +5,7 @@ let jwt = ""; function getUserJwt(username) { - fetch(`/api/jwt/?username=${username}`) + fetch(`/config/jwt/?username=${username}`) .then((res) => res.text()) .then((text) => { jwt = text; From 1072d7b449af46c9252f7135687294e68af8e5d5 Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Thu, 4 Jul 2024 11:16:45 +0200 Subject: [PATCH 2/8] Improve auth handling of rest apis used in frontend for compatibility --- cmd/cc-backend/main.go | 41 +++++++++++++++++- internal/api/rest.go | 14 ++++--- internal/auth/auth.go | 42 +++++++++++++++++++ web/frontend/src/config/PlotSettings.svelte | 10 ++--- .../src/config/admin/ShowUsersRow.svelte | 2 +- 5 files changed, 96 insertions(+), 13 deletions(-) diff --git a/cmd/cc-backend/main.go b/cmd/cc-backend/main.go index 0ac66c1..fdddb99 100644 --- a/cmd/cc-backend/main.go +++ b/cmd/cc-backend/main.go @@ -373,6 +373,8 @@ func main() { secured := r.PathPrefix("/").Subrouter() securedapi := r.PathPrefix("/api").Subrouter() userapi := r.PathPrefix("/userapi").Subrouter() + configapi := r.PathPrefix("/config").Subrouter() + userconfigapi := r.PathPrefix("/userconfig").Subrouter() if !config.Keys.DisableAuthentication { r.Handle("/login", authentication.Login( @@ -475,6 +477,42 @@ func main() { }) }) }) + + configapi.Use(func(next http.Handler) http.Handler { + return authentication.AuthConfigApi( + // On success; + next, + + // On failure: + func(rw http.ResponseWriter, r *http.Request, err error) { + rw.WriteHeader(http.StatusUnauthorized) + web.RenderTemplate(rw, "login.tmpl", &web.Page{ + Title: "Authentication failed - ClusterCockpit", + MsgType: "alert-danger", + Message: err.Error(), + Build: buildInfo, + Infos: info, + }) + }) + }) + + userconfigapi.Use(func(next http.Handler) http.Handler { + return authentication.AuthUserConfigApi( + // On success; + next, + + // On failure: + func(rw http.ResponseWriter, r *http.Request, err error) { + rw.WriteHeader(http.StatusUnauthorized) + web.RenderTemplate(rw, "login.tmpl", &web.Page{ + Title: "Authentication failed - ClusterCockpit", + MsgType: "alert-danger", + Message: err.Error(), + Build: buildInfo, + Infos: info, + }) + }) + }) } if flagDev { @@ -491,9 +529,10 @@ func main() { // Mount all /monitoring/... and /api/... routes. routerConfig.SetupRoutes(secured, buildInfo) - api.MountConfigApiRoutes(secured) api.MountApiRoutes(securedapi) api.MountUserApiRoutes(userapi) + api.MountConfigApiRoutes(configapi) + api.MountUserConfigApiRoutes(userconfigapi) if config.Keys.EmbedStaticFiles { if i, err := os.Stat("./var/img"); err == nil { diff --git a/internal/api/rest.go b/internal/api/rest.go index cd4a497..17a3183 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -92,23 +92,25 @@ func (api *RestApi) MountUserApiRoutes(r *mux.Router) { 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) - - if api.Authentication != nil { - r.HandleFunc("/jwt/", api.getJWT).Methods(http.MethodGet) - } } func (api *RestApi) MountConfigApiRoutes(r *mux.Router) { - r = r.PathPrefix("/config").Subrouter() 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) MountUserConfigApiRoutes(r *mux.Router) { + r.StrictSlash(true) + + if api.Authentication != nil { + r.HandleFunc("/jwt/", api.getJWT).Methods(http.MethodGet) // Role:Admin Check in r.HandleFunc("/configuration/", api.updateConfiguration).Methods(http.MethodPost) } } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 9b42072..7e6f30e 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -314,6 +314,48 @@ func (auth *Authentication) AuthUserApi( }) } +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("authentication failed: %s", err.Error()) + http.Error(rw, err.Error(), http.StatusUnauthorized) + return + } + if user != nil && user.HasRole(schema.RoleAdmin) { + ctx := context.WithValue(r.Context(), repository.ContextUserKey, user) + onsuccess.ServeHTTP(rw, r.WithContext(ctx)) + return + } + log.Debug("authentication failed") + onfailure(rw, r, errors.New("unauthorized (no auth)")) + }) +} + +func (auth *Authentication) AuthUserConfigApi( + 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("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") + onfailure(rw, r, errors.New("unauthorized (no auth)")) + }) +} + 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/web/frontend/src/config/PlotSettings.svelte b/web/frontend/src/config/PlotSettings.svelte index 93018a3..b5ea5e5 100644 --- a/web/frontend/src/config/PlotSettings.svelte +++ b/web/frontend/src/config/PlotSettings.svelte @@ -283,7 +283,7 @@ handleSettingSubmit("#line-width-form", "lw")} @@ -329,7 +329,7 @@ handleSettingSubmit("#plots-per-row-form", "ppr")} @@ -375,7 +375,7 @@ handleSettingSubmit("#backgrounds-form", "bg")} @@ -429,7 +429,7 @@ handleSettingSubmit("#paging-form", "pag")} @@ -485,7 +485,7 @@ diff --git a/web/frontend/src/config/admin/ShowUsersRow.svelte b/web/frontend/src/config/admin/ShowUsersRow.svelte index 4dae0c9..2971365 100644 --- a/web/frontend/src/config/admin/ShowUsersRow.svelte +++ b/web/frontend/src/config/admin/ShowUsersRow.svelte @@ -5,7 +5,7 @@ let jwt = ""; function getUserJwt(username) { - fetch(`/config/jwt/?username=${username}`) + fetch(`/userconfig/jwt/?username=${username}`) .then((res) => res.text()) .then((text) => { jwt = text; From 614f6947779b382c5c216b166cb418e6914f79e0 Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Thu, 4 Jul 2024 11:41:17 +0200 Subject: [PATCH 3/8] fix typo in api url --- web/frontend/src/config/PlotSettings.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/frontend/src/config/PlotSettings.svelte b/web/frontend/src/config/PlotSettings.svelte index b5ea5e5..60a3d7a 100644 --- a/web/frontend/src/config/PlotSettings.svelte +++ b/web/frontend/src/config/PlotSettings.svelte @@ -283,7 +283,7 @@ handleSettingSubmit("#line-width-form", "lw")} From 9d4767539c1b6cf68c4355fa4a4f70a17619422f Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Thu, 4 Jul 2024 17:30:16 +0200 Subject: [PATCH 4/8] Restructure config frontend, add user jwt request --- web/frontend/src/Config.root.svelte | 15 +- web/frontend/src/config.entrypoint.js | 4 +- web/frontend/src/config/PlotSettings.svelte | 552 ------------------ web/frontend/src/config/UserSettings.svelte | 47 ++ .../src/config/admin/ShowUsersRow.svelte | 17 +- .../src/config/user/PlotColorScheme.svelte | 329 +++++++++++ .../src/config/user/PlotRenderOptions.svelte | 166 ++++++ .../src/config/user/UserOptions.svelte | 131 +++++ web/frontend/src/utils.js | 12 + web/templates/config.tmpl | 2 + 10 files changed, 705 insertions(+), 570 deletions(-) delete mode 100644 web/frontend/src/config/PlotSettings.svelte create mode 100644 web/frontend/src/config/UserSettings.svelte create mode 100644 web/frontend/src/config/user/PlotColorScheme.svelte create mode 100644 web/frontend/src/config/user/PlotRenderOptions.svelte create mode 100644 web/frontend/src/config/user/UserOptions.svelte 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/PlotSettings.svelte b/web/frontend/src/config/PlotSettings.svelte deleted file mode 100644 index 60a3d7a..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} -
- -
- - -
- Width of the lines in the timeseries plots. -
-
- - -
- - - -
- handleSettingSubmit("#plots-per-row-form", "ppr")} - > - - -
Plots per Row
- {#if displayMessage && message.target == "ppr"}
- Update: {message.msg} -
{/if} -
- -
- - -
- How many plots to show next to each other on pages such as - /monitoring/job/, /monitoring/system/... -
-
- -
-
- - - -
- handleSettingSubmit("#backgrounds-form", "bg")} - > - - -
Colored Backgrounds
- {#if displayMessage && message.target == "bg"}
- Update: {message.msg} -
{/if} -
- -
-
- {#if config.plot_general_colorBackground} - - {:else} - - {/if} - -
-
- {#if config.plot_general_colorBackground} - - {:else} - - {/if} - -
-
- -
-
- - - -
- handleSettingSubmit("#paging-form", "pag")} - > - - -
Paging Type
- {#if displayMessage && message.target == "pag"}
- Update: {message.msg} -
{/if} -
- -
-
- {#if config.job_list_usePaging} - - {:else} - - {/if} - -
-
- {#if config.job_list_usePaging} - - {:else} - - {/if} - -
-
- -
-
-
- - - - -
- - -
Color Scheme for Timeseries Plots
- {#if displayMessage && message.target == "cs"}
- Update: {message.msg} -
{/if} -
- - - - {#each Object.entries(colorschemes) as [name, rgbrow]} - - - - - - {/each} - -
{name} - {#if rgbrow.join(",") == config.plot_general_colorscheme} - - handleSettingSubmit("#colorscheme-form", "cs")} - /> - {:else} - - handleSettingSubmit("#colorscheme-form", "cs")} - /> - {/if} - - {#each rgbrow as rgb} - - {/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/ShowUsersRow.svelte b/web/frontend/src/config/admin/ShowUsersRow.svelte index 2971365..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..ad73791 --- /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]} + + + + + + {/each} + +
{name} + {#if rgbrow.join(",") == config.plot_general_colorscheme} + + updateSetting("#colorscheme-form", "cs")} + /> + {:else} + + updateSetting("#colorscheme-form", "cs")} + /> + {/if} + + {#each rgbrow as rgb} + + {/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..eb96eda --- /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} +
+ +
+ + +
+ Width of the lines in the timeseries plots. +
+
+ +
+
+ + + +
+ updateSetting("#plots-per-row-form", "ppr")} + > + + +
Plots per Row
+ {#if displayMessage && message.target == "ppr"}
+ Update: {message.msg} +
{/if} +
+ +
+ + +
+ How many plots to show next to each other on pages such as + /monitoring/job/, /monitoring/system/... +
+
+ +
+
+ + + +
+ updateSetting("#backgrounds-form", "bg")} + > + + +
Colored Backgrounds
+ {#if displayMessage && message.target == "bg"}
+ Update: {message.msg} +
{/if} +
+ +
+
+ {#if config.plot_general_colorBackground} + + {:else} + + {/if} + +
+
+ {#if config.plot_general_colorBackground} + + {:else} + + {/if} + +
+
+ +
+
+
\ 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..fe63118 --- /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} + +
+
+ {#if config.job_list_usePaging} + + {:else} + + {/if} + +
+
+ +
+
+ + + {#if isApi} + + + + + Generate JWT + {#if jwt} + +

+ Your token is displayed on the right. Press this button to copy it to the clipboard. +

+ {:else} + +

+ Generate a JSON Web Token for use with the ClusterCockpit REST-API endpoints. +

+ {/if} +
+
+ + + + + + + Display JWT + + + + + {/if} +
\ No newline at end of file diff --git a/web/frontend/src/utils.js b/web/frontend/src/utils.js index 5346208..48bca6b 100644 --- a/web/frontend/src/utils.js +++ b/web/frontend/src/utils.js @@ -433,6 +433,18 @@ export function transformPerNodeDataForRoofline(nodes) { return data } +export async function fetchJwt(username) { + const raw = await fetch(`/userconfig/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"}} From 3afe40083d6bde9d4ba7db2f094b3df9c559cb22 Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Fri, 5 Jul 2024 11:48:06 +0200 Subject: [PATCH 5/8] rename api userconfig to frontend, return json on api auth error --- cmd/cc-backend/main.go | 56 ++++++++----------- internal/api/rest.go | 5 +- internal/auth/auth.go | 50 ++++++++--------- internal/repository/job.go | 24 +++++++- .../src/config/user/PlotColorScheme.svelte | 2 +- .../src/config/user/PlotRenderOptions.svelte | 6 +- .../src/config/user/UserOptions.svelte | 2 +- web/frontend/src/utils.js | 4 +- 8 files changed, 80 insertions(+), 69 deletions(-) diff --git a/cmd/cc-backend/main.go b/cmd/cc-backend/main.go index fdddb99..b0faa13 100644 --- a/cmd/cc-backend/main.go +++ b/cmd/cc-backend/main.go @@ -374,7 +374,7 @@ func main() { securedapi := r.PathPrefix("/api").Subrouter() userapi := r.PathPrefix("/userapi").Subrouter() configapi := r.PathPrefix("/config").Subrouter() - userconfigapi := r.PathPrefix("/userconfig").Subrouter() + frontendapi := r.PathPrefix("/frontend").Subrouter() if !config.Keys.DisableAuthentication { r.Handle("/login", authentication.Login( @@ -447,15 +447,13 @@ func main() { // On success; next, - // On failure: + // On failure: JSON Response func(rw http.ResponseWriter, r *http.Request, err error) { + rw.Header().Add("Content-Type", "application/json") rw.WriteHeader(http.StatusUnauthorized) - web.RenderTemplate(rw, "login.tmpl", &web.Page{ - Title: "Authentication failed - ClusterCockpit", - MsgType: "alert-danger", - Message: err.Error(), - Build: buildInfo, - Infos: info, + json.NewEncoder(rw).Encode(map[string]string{ + "status": http.StatusText(http.StatusUnauthorized), + "error": err.Error(), }) }) }) @@ -465,15 +463,13 @@ func main() { // On success; next, - // On failure: + // On failure: JSON Response func(rw http.ResponseWriter, r *http.Request, err error) { + rw.Header().Add("Content-Type", "application/json") rw.WriteHeader(http.StatusUnauthorized) - web.RenderTemplate(rw, "login.tmpl", &web.Page{ - Title: "Authentication failed - ClusterCockpit", - MsgType: "alert-danger", - Message: err.Error(), - Build: buildInfo, - Infos: info, + json.NewEncoder(rw).Encode(map[string]string{ + "status": http.StatusText(http.StatusUnauthorized), + "error": err.Error(), }) }) }) @@ -483,33 +479,29 @@ func main() { // On success; next, - // On failure: + // On failure: JSON Response func(rw http.ResponseWriter, r *http.Request, err error) { + rw.Header().Add("Content-Type", "application/json") rw.WriteHeader(http.StatusUnauthorized) - web.RenderTemplate(rw, "login.tmpl", &web.Page{ - Title: "Authentication failed - ClusterCockpit", - MsgType: "alert-danger", - Message: err.Error(), - Build: buildInfo, - Infos: info, + json.NewEncoder(rw).Encode(map[string]string{ + "status": http.StatusText(http.StatusUnauthorized), + "error": err.Error(), }) }) }) - userconfigapi.Use(func(next http.Handler) http.Handler { - return authentication.AuthUserConfigApi( + frontendapi.Use(func(next http.Handler) http.Handler { + return authentication.AuthFrontendApi( // On success; next, - // On failure: + // On failure: JSON Response func(rw http.ResponseWriter, r *http.Request, err error) { + rw.Header().Add("Content-Type", "application/json") rw.WriteHeader(http.StatusUnauthorized) - web.RenderTemplate(rw, "login.tmpl", &web.Page{ - Title: "Authentication failed - ClusterCockpit", - MsgType: "alert-danger", - Message: err.Error(), - Build: buildInfo, - Infos: info, + json.NewEncoder(rw).Encode(map[string]string{ + "status": http.StatusText(http.StatusUnauthorized), + "error": err.Error(), }) }) }) @@ -532,7 +524,7 @@ func main() { api.MountApiRoutes(securedapi) api.MountUserApiRoutes(userapi) api.MountConfigApiRoutes(configapi) - api.MountUserConfigApiRoutes(userconfigapi) + api.MountFrontendApiRoutes(frontendapi) if config.Keys.EmbedStaticFiles { if i, err := os.Stat("./var/img"); err == nil { diff --git a/internal/api/rest.go b/internal/api/rest.go index 17a3183..b447a21 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -106,12 +106,13 @@ func (api *RestApi) MountConfigApiRoutes(r *mux.Router) { } } -func (api *RestApi) MountUserConfigApiRoutes(r *mux.Router) { +func (api *RestApi) MountFrontendApiRoutes(r *mux.Router) { r.StrictSlash(true) if api.Authentication != nil { - r.HandleFunc("/jwt/", api.getJWT).Methods(http.MethodGet) // Role:Admin Check in + 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 } } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 7e6f30e..50f4121 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -219,27 +219,25 @@ 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)")) }) } @@ -251,8 +249,8 @@ func (auth *Authentication) AuthApi( 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()) - http.Error(rw, err.Error(), http.StatusUnauthorized) + log.Infof("auth api -> authentication failed: %s", err.Error()) + onfailure(rw, r, err) return } if user != nil { @@ -270,12 +268,12 @@ func (auth *Authentication) AuthApi( return } default: - log.Debug("authentication failed") - onfailure(rw, r, errors.New("unauthorized (missing role)")) + log.Info("auth api -> authentication failed: missing role") + onfailure(rw, r, errors.New("unauthorized")) } } - log.Debug("authentication failed") - onfailure(rw, r, errors.New("unauthorized (no auth)")) + log.Info("auth api -> authentication failed: no auth") + onfailure(rw, r, errors.New("unauthorized")) }) } @@ -286,8 +284,8 @@ func (auth *Authentication) AuthUserApi( 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()) - http.Error(rw, err.Error(), http.StatusUnauthorized) + log.Infof("auth user api -> authentication failed: %s", err.Error()) + onfailure(rw, r, err) return } if user != nil { @@ -305,12 +303,12 @@ func (auth *Authentication) AuthUserApi( return } default: - log.Debug("authentication failed") - onfailure(rw, r, errors.New("unauthorized (missing role)")) + log.Info("auth user api -> authentication failed: missing role") + onfailure(rw, r, errors.New("unauthorized")) } } - log.Debug("authentication failed") - onfailure(rw, r, errors.New("unauthorized (no auth)")) + log.Info("auth user api -> authentication failed: no auth") + onfailure(rw, r, errors.New("unauthorized")) }) } @@ -321,8 +319,8 @@ func (auth *Authentication) AuthConfigApi( return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { user, err := auth.AuthViaSession(rw, r) if err != nil { - log.Infof("authentication failed: %s", err.Error()) - http.Error(rw, err.Error(), http.StatusUnauthorized) + log.Infof("auth config api -> authentication failed: %s", err.Error()) + onfailure(rw, r, err) return } if user != nil && user.HasRole(schema.RoleAdmin) { @@ -330,20 +328,20 @@ func (auth *Authentication) AuthConfigApi( onsuccess.ServeHTTP(rw, r.WithContext(ctx)) return } - log.Debug("authentication failed") - onfailure(rw, r, errors.New("unauthorized (no auth)")) + log.Info("auth config api -> authentication failed: no auth") + onfailure(rw, r, errors.New("unauthorized")) }) } -func (auth *Authentication) AuthUserConfigApi( +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("authentication failed: %s", err.Error()) - http.Error(rw, err.Error(), http.StatusUnauthorized) + log.Infof("auth frontend api -> authentication failed: %s", err.Error()) + onfailure(rw, r, err) return } if user != nil { @@ -351,8 +349,8 @@ func (auth *Authentication) AuthUserConfigApi( onsuccess.ServeHTTP(rw, r.WithContext(ctx)) return } - log.Debug("authentication failed") - onfailure(rw, r, errors.New("unauthorized (no auth)")) + log.Info("auth frontend api -> authentication failed: no auth") + onfailure(rw, r, errors.New("unauthorized")) }) } diff --git a/internal/repository/job.go b/internal/repository/job.go index ddd93d3..20496b2 100644 --- a/internal/repository/job.go +++ b/internal/repository/job.go @@ -305,16 +305,36 @@ func (r *JobRepository) FindByIdDirect(jobId int64) (*schema.Job, error) { 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, user string, cluster string) bool { +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.cluster = ?", cluster). + Where("job.start_time = ?", startTime) _, err := scanJob(q.RunWith(r.stmtCache).QueryRow()) return err != sql.ErrNoRows diff --git a/web/frontend/src/config/user/PlotColorScheme.svelte b/web/frontend/src/config/user/PlotColorScheme.svelte index ad73791..a36f47c 100644 --- a/web/frontend/src/config/user/PlotColorScheme.svelte +++ b/web/frontend/src/config/user/PlotColorScheme.svelte @@ -262,7 +262,7 @@
diff --git a/web/frontend/src/config/user/PlotRenderOptions.svelte b/web/frontend/src/config/user/PlotRenderOptions.svelte index eb96eda..a237ed3 100644 --- a/web/frontend/src/config/user/PlotRenderOptions.svelte +++ b/web/frontend/src/config/user/PlotRenderOptions.svelte @@ -30,7 +30,7 @@ updateSetting("#line-width-form", "lw")} @@ -76,7 +76,7 @@ updateSetting("#plots-per-row-form", "ppr")} @@ -122,7 +122,7 @@ updateSetting("#backgrounds-form", "bg")} diff --git a/web/frontend/src/config/user/UserOptions.svelte b/web/frontend/src/config/user/UserOptions.svelte index fe63118..d589414 100644 --- a/web/frontend/src/config/user/UserOptions.svelte +++ b/web/frontend/src/config/user/UserOptions.svelte @@ -51,7 +51,7 @@ updateSetting("#paging-form", "pag")} diff --git a/web/frontend/src/utils.js b/web/frontend/src/utils.js index 48bca6b..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( "&" )}` ); @@ -434,7 +434,7 @@ export function transformPerNodeDataForRoofline(nodes) { } export async function fetchJwt(username) { - const raw = await fetch(`/userconfig/jwt/?username=${username}`); + const raw = await fetch(`/frontend/jwt/?username=${username}`); if (!raw.ok) { const message = `An error has occured: ${response.status}`; From 63fb9239953666eeac4d8dd1f5cadd6bf796b621 Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Fri, 5 Jul 2024 13:16:21 +0200 Subject: [PATCH 6/8] fix: fix api test router init --- internal/api/api_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/api/api_test.go b/internal/api/api_test.go index e91357e..725563c 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -197,6 +197,8 @@ func TestRestApi(t *testing.T) { } r := mux.NewRouter() + r.PathPrefix("/api").Subrouter() + r.StrictSlash(true) restapi.MountApiRoutes(r) const startJobBody string = `{ From be9df7649f63f7b7805a107f681130ecbffd3e6c Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Fri, 5 Jul 2024 15:25:24 +0200 Subject: [PATCH 7/8] fix: setup user in api test config --- internal/api/api_test.go | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 725563c..e8f477e 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -227,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()) @@ -242,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) } From 0a604336c47c599e23f9697e524dd3532b3c6eca Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Fri, 5 Jul 2024 15:42:08 +0200 Subject: [PATCH 8/8] Fix other apitest subtests --- internal/api/api_test.go | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/internal/api/api_test.go b/internal/api/api_test.go index e8f477e..0354a0f 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -302,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) } @@ -354,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()) @@ -384,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()) @@ -406,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())